Explorar o código

Save the form's registry lookup whole and retry failed inspection checks (#484)

* Save the form's registry lookup whole and retry failed inspection checks

A vehicle saved from a plate or VIN lookup now records the registry's
whole answer the way the hourly sync does: the day it gave read as a UTC
day, the registered flag, last inspection and extras, and the time of the
check, so the vehicle is not looked up again and the date shows as the
registry gave it in every time zone. The answer is kept only while the
plate or VIN it was about is still the one saved, and a date typed over it
is saved by hand on top. A date left as the form opened with is no longer
sent, so editing another field does not turn a registry's date into a
hand-typed one; a fresh lookup on an existing vehicle replaces the date
the form opened with.

The sync retries a failed lookup after 30 days whatever the cadence, so an
outage does not hold a vehicle's date for a quarter.

* Tie a form lookup to the identifier it asked about, at both ends

The lookup button hands the form the plate or VIN it asked about, read
when it was pressed, so an answer is never labelled with a value retyped
while the request was out. The lookup action records an answer on an
existing vehicle at once only when it was asked about that vehicle's own
plate or VIN; an answer about another plate is left to the save, which
keeps it only if that plate is kept. A date a lookup wrote and nobody
touched is never saved as typed, not even when the plate under it changed,
and a second lookup may replace the date the first one wrote. A date left
as the form opened with is omitted whether or not a lookup was made.
Bernt Christian Egeland hai 1 semana
pai
achega
7788df394b

+ 11 - 4
src/__tests__/features/integrations/inspection-sync.test.ts

@@ -65,8 +65,14 @@ describe('inspection refresh job', () => {
     expect(where.isArchived).toBe(false)
     expect(where.licensePlate).toEqual({ not: null })
     expect(where).not.toHaveProperty('customerId')
-    // Never checked, no date yet, monthly, and weekly near the deadline.
-    expect(where.OR).toHaveLength(5)
+    // Never checked, no date yet, monthly, weekly near the deadline, and failed a month ago.
+    expect(where.OR).toHaveLength(6)
+    expect(where.OR[5]).toEqual({
+      inspectionStatus: {
+        lastError: { not: null },
+        checkedAt: { lt: new Date('2026-08-04T08:00:00Z') },
+      },
+    })
     expect(where.OR[3]).toEqual({
       inspectionStatus: { checkedAt: { lt: new Date('2026-08-04T08:00:00Z') } },
     })
@@ -87,11 +93,12 @@ describe('inspection refresh job', () => {
     })
     const where = vehicle.findMany.mock.calls[0][0].where
     expect(where.customerId).toEqual({ not: null })
-    // No cadence rule: never checked, no date yet, and near the deadline.
-    expect(where.OR).toHaveLength(4)
+    // No cadence rule: never checked, no date yet, near the deadline, and failed a month ago.
+    expect(where.OR).toHaveLength(5)
     expect(where.OR[3].inspectionStatus.dueAt).toEqual({
       lt: new Date('2026-10-03T08:00:00Z'),
     })
+    expect(where.OR[4].inspectionStatus.lastError).toEqual({ not: null })
   })
 
   it('reads the cadence off the connection settings, falling back to the defaults', () => {

+ 24 - 1
src/__tests__/features/integrations/vin-lookup.test.ts

@@ -180,9 +180,14 @@ describe('lookupVehicle by VIN', () => {
     expect(recordRegistryAnswer).not.toHaveBeenCalled()
   })
 
-  it('still records what a registry says when it was asked by VIN', async () => {
+  it('still records what a registry says when it was asked by the vehicle’s own VIN', async () => {
     country('+47')
     connected('vegvesen')
+    vi.mocked(db.vehicle.findFirst).mockResolvedValue({
+      id: 'v1',
+      licensePlate: 'AB12345',
+      vin: VIN.toLowerCase(),
+    } as never)
     vi.mocked(askRegistry).mockResolvedValue({
       result: { make: 'Toyota', vin: VIN, inspectionDue: '2027-04-26' },
       source: 'Statens vegvesen',
@@ -193,6 +198,24 @@ describe('lookupVehicle by VIN', () => {
     expect(recordRegistryAnswer).toHaveBeenCalledTimes(1)
   })
 
+  it('leaves an answer about some other VIN to the save, which checks the VIN kept', async () => {
+    country('+47')
+    connected('vegvesen')
+    vi.mocked(db.vehicle.findFirst).mockResolvedValue({
+      id: 'v1',
+      licensePlate: 'AB12345',
+      vin: 'WVWZZZ1JZXW000001',
+    } as never)
+    vi.mocked(askRegistry).mockResolvedValue({
+      result: { make: 'Toyota', vin: VIN, inspectionDue: '2027-04-26' },
+      source: 'Statens vegvesen',
+      connectorId: 'vegvesen',
+    })
+    const res = await lookupVehicle({ by: 'vin', value: VIN, vehicleId: 'v1' })
+    expect(res).toMatchObject({ success: true, data: { make: 'Toyota' } })
+    expect(recordRegistryAnswer).not.toHaveBeenCalled()
+  })
+
   it('says so when nothing connected decodes VINs', async () => {
     connected('regcheck')
     const res = await lookupVehicle({ by: 'vin', value: VIN })

+ 61 - 0
src/__tests__/features/vehicles/registry-lookup.test.ts

@@ -0,0 +1,61 @@
+import { describe, expect, it } from 'vitest'
+import {
+  holdsRegistryDate,
+  identifierKey,
+  lookupAppliesTo,
+  registryLookupSchema,
+} from '@/features/vehicles/Lib/registryLookup'
+
+describe('registry lookup saved with the vehicle form', () => {
+  it('reduces a plate or VIN to its letters and digits', () => {
+    expect(identifierKey(' ab-12 345 ')).toBe('AB12345')
+    expect(identifierKey('')).toBe('')
+  })
+
+  it('applies to the vehicle whose plate was asked about, however it is written', () => {
+    const lookup = { by: 'plate' as const, value: 'AB 12345' }
+    expect(lookupAppliesTo(lookup, { licensePlate: 'ab-12345' })).toBe(true)
+    // The plate was retyped after the lookup: another vehicle.
+    expect(lookupAppliesTo(lookup, { licensePlate: 'CD 67890' })).toBe(false)
+    expect(lookupAppliesTo(lookup, { licensePlate: null })).toBe(false)
+    expect(lookupAppliesTo({ by: 'plate', value: '' }, { licensePlate: '' })).toBe(false)
+  })
+
+  it('matches a VIN lookup against the VIN, not the plate', () => {
+    const lookup = { by: 'vin' as const, value: '1HGCM82633A004352' }
+    expect(lookupAppliesTo(lookup, { vin: '1hgcm82633a004352', licensePlate: 'X' })).toBe(true)
+    expect(lookupAppliesTo(lookup, { vin: null, licensePlate: '1HGCM82633A004352' })).toBe(false)
+  })
+
+  it('tells a date left as the registry gave it from one typed over it', () => {
+    const result = { inspectionDue: '2027-03-31' }
+    expect(holdsRegistryDate(undefined, result)).toBe(true)
+    expect(holdsRegistryDate('2027-03-31', result)).toBe(true)
+    expect(holdsRegistryDate('2027-04-15', result)).toBe(false)
+    expect(holdsRegistryDate('', result)).toBe(false)
+    // A registry without a date gave nothing the field could still hold.
+    expect(holdsRegistryDate(undefined, {})).toBe(true)
+    expect(holdsRegistryDate('', {})).toBe(false)
+    expect(holdsRegistryDate('2027-04-15', {})).toBe(false)
+  })
+
+  it('keeps only what the inspection status stores from the form’s lookup', () => {
+    const parsed = registryLookupSchema.parse({
+      connectorId: 'checkcardetails',
+      by: 'plate',
+      value: 'AB12 CDE',
+      result: {
+        make: 'Ford',
+        model: 'Focus',
+        inspectionDue: '2027-03-31',
+        registered: false,
+        tyres: [{ axle: 1, tyre: '205/55 R16' }],
+      },
+    })
+    expect(parsed.result).toEqual({
+      inspectionDue: '2027-03-31',
+      registered: false,
+      tyres: [{ axle: 1, tyre: '205/55 R16' }],
+    })
+  })
+})

+ 8 - 5
src/features/integrations/Actions/vehicleLookupActions.ts

@@ -4,6 +4,7 @@ import { db } from '@/lib/db'
 import { getFeatures } from '@/lib/features'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { withAuth } from '@/lib/with-auth'
+import { lookupAppliesTo } from '@/features/vehicles/Lib/registryLookup'
 import { recordRegistryAnswer } from '../Lib/inspection-sync'
 import type { VehicleLookupKey, VehicleLookupResult } from '../Lib/types'
 import {
@@ -52,9 +53,11 @@ export async function isVehicleLookupAvailable() {
 
 /**
  * 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.
+ * answers to it. When the vehicle already exists, is this organisation's,
+ * and the plate or VIN asked about is its own, what the registry said is
+ * also recorded on it, so the inspection date lands without waiting for the
+ * next scheduled pass. An answer about some other plate typed into its form
+ * is left to the save, which records it only if that plate is kept.
  */
 export async function lookupVehicle(query: {
   by: VehicleLookupKey
@@ -89,9 +92,9 @@ export async function lookupVehicle(query: {
       if (query.vehicleId && lookupKeys(target.connectorId).includes('plate')) {
         const owned = await db.vehicle.findFirst({
           where: { id: query.vehicleId, organizationId },
-          select: { id: true },
+          select: { id: true, licensePlate: true, vin: true },
         })
-        if (owned) {
+        if (owned && lookupAppliesTo({ by, value }, owned)) {
           await recordRegistryAnswer({
             organizationId,
             vehicleId: owned.id,

+ 9 - 4
src/features/integrations/Lib/inspection-sync.ts

@@ -152,6 +152,8 @@ export class RegistryQuotaError extends Error {
 export const RECHECK_SOON_DAYS = 7
 /** A vehicle the register knows but has given no date yet is rechecked this often, whatever the cadence. */
 export const RECHECK_NO_DATE_DAYS = 30
+/** A lookup that failed is tried again this often, whatever the cadence, so an outage does not hold a date for a quarter. */
+export const RETRY_FAILED_DAYS = 30
 /** Lookups per job run; the job reschedules itself while more remain. */
 export const BATCH_SIZE = 25
 const BATCH_PAUSE_SECONDS = 5
@@ -254,10 +256,10 @@ function askableVehicles(organizationId: string, options: InspectionSyncOptions)
 }
 
 /**
- * The subset of those due a check now. Always: never checked, or known to
- * the register without a date and not asked for a month. On the chosen
- * cadence: every vehicle whose last check is older than it. And near the
- * deadline: weekly, whatever the cadence.
+ * The subset of those due a check now. Always: never checked, known to the
+ * register without a date and not asked for a month, or failed last time and
+ * not retried for a month. On the chosen cadence: every vehicle whose last
+ * check is older than it. And near the deadline: weekly, whatever the cadence.
  */
 function dueForCheckWhere(organizationId: string, now: Date, options: InspectionSyncOptions) {
   const ago = (days: number) => new Date(now.getTime() - days * DAY_MS)
@@ -274,6 +276,9 @@ function dueForCheckWhere(organizationId: string, now: Date, options: Inspection
       {
         inspectionStatus: { dueAt: { lt: soonCutoff }, checkedAt: { lt: ago(RECHECK_SOON_DAYS) } },
       },
+      {
+        inspectionStatus: { lastError: { not: null }, checkedAt: { lt: ago(RETRY_FAILED_DAYS) } },
+      },
     ],
   }
 }

+ 49 - 27
src/features/vehicles/Actions/vehicleActions.ts

@@ -1,6 +1,9 @@
 'use server'
 
-import { INSPECTION_CAPABILITY } from '@/features/integrations/Lib/inspection-sync'
+import {
+  INSPECTION_CAPABILITY,
+  recordRegistryAnswer,
+} from '@/features/integrations/Lib/inspection-sync'
 import { getManifest } from '@/integrations/registry'
 import { workshopTimeZone } from '@/lib/workshop-timezone'
 import { assertOwnUploads } from '@/lib/upload-url'
@@ -10,6 +13,7 @@ import { assertOwnedCustomer } from '@/lib/owned-records'
 import { withAuth } from '@/lib/with-auth'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { createVehicleSchema, updateVehicleSchema } from '../Schema/vehicleSchema'
+import { holdsRegistryDate, lookupAppliesTo, type RegistryLookup } from '../Lib/registryLookup'
 import { revalidatePath } from 'next/cache'
 import { releaseFiles } from '@/lib/files/manager'
 import { vehicleFileUrls } from '@/lib/files/collect'
@@ -210,12 +214,6 @@ export async function getVehiclesPaginated(params: {
   )
 }
 
-/**
- * A date typed on the vehicle form. Kept beside the registry's answer with
- * its own source, so a workshop in a country without a registry, or one
- * that knows better than the register, can still drive reminders. A
- * registry answer with a date takes over again on the next sync.
- */
 /** A connector id that is a registry able to keep inspection dates, or null for anything else. */
 function inspectionRegistry(source: string | undefined): string | null {
   if (!source) return null
@@ -224,17 +222,41 @@ function inspectionRegistry(source: string | undefined): string | null {
 }
 
 /**
- * The form's inspection date. Typed by hand, it is saved as manual. Filled
- * by a registry lookup and left alone, it is saved as that registry's
- * answer with the time of the check, so the hourly sync treats the vehicle
- * as freshly checked instead of spending a second lookup on it.
+ * The form's inspection date and the registry lookup behind it.
+ *
+ * A lookup made on the form is recorded whole, the way the hourly sync
+ * records its own, with the registry's day read as a UTC day and the time of
+ * the check, so the sync treats the vehicle as freshly checked instead of
+ * spending a second lookup on it. Only when it was about this vehicle: a
+ * plate retyped after the lookup gets no answer that was never about it.
+ *
+ * The date field is then saved by hand only when the workshop typed
+ * something other than what the registry said; a date the lookup wrote and
+ * nobody touched is never saved as typed, not even when the plate was retyped
+ * underneath it. A typed date is kept beside
+ * the registry's answer with its own source, so a workshop in a country
+ * without a registry, or one that knows better than the register, can still
+ * drive reminders; a registry date takes over again on the next sync.
  */
 async function saveInspectionDate(
   organizationId: string,
   vehicleId: string,
   value: string | undefined,
-  source?: string
+  lookup: RegistryLookup | undefined,
+  saved: { licensePlate?: string | null; vin?: string | null }
 ): Promise<void> {
+  if (lookup) {
+    const registry = lookupAppliesTo(lookup, saved) ? inspectionRegistry(lookup.connectorId) : null
+    if (registry) {
+      await recordRegistryAnswer({
+        organizationId,
+        vehicleId,
+        source: registry,
+        result: lookup.result,
+      })
+    }
+    if (holdsRegistryDate(value, lookup.result)) return
+  }
   if (value === undefined) return
   const trimmed = value.trim()
   if (!trimmed) {
@@ -247,16 +269,6 @@ async function saveInspectionDate(
   }
   const dueAt = toSafeWorkshopDate(trimmed, await workshopTimeZone(organizationId))
   if (!dueAt) throw new Error('Invalid inspection date')
-  const registry = inspectionRegistry(source)
-  if (registry) {
-    const now = new Date()
-    await db.vehicleInspectionStatus.upsert({
-      where: { vehicleId },
-      create: { organizationId, vehicleId, dueAt, source: registry, checkedAt: now, found: true },
-      update: { dueAt, source: registry, checkedAt: now, found: true, lastError: null },
-    })
-    return
-  }
   await db.vehicleInspectionStatus.upsert({
     where: { vehicleId },
     create: { organizationId, vehicleId, dueAt, source: 'manual', found: true },
@@ -267,7 +279,7 @@ async function saveInspectionDate(
 export async function createVehicle(input: unknown) {
   return withAuth(
     async ({ userId, organizationId }) => {
-      const { inspectionDueAt, inspectionDueSource, ...data } = createVehicleSchema.parse(input)
+      const { inspectionDueAt, registryLookup, ...data } = createVehicleSchema.parse(input)
       assertOwnUploads(data, organizationId)
       // The owner the vehicle is filed under must be one of our customers.
       await assertOwnedCustomer(data.customerId, organizationId)
@@ -283,7 +295,7 @@ export async function createVehicle(input: unknown) {
           organizationId,
         },
       })
-      await saveInspectionDate(organizationId, vehicle.id, inspectionDueAt, inspectionDueSource)
+      await saveInspectionDate(organizationId, vehicle.id, inspectionDueAt, registryLookup, vehicle)
       revalidatePath('/')
       revalidatePath('/vehicles')
       return vehicle
@@ -309,13 +321,20 @@ export async function createVehicle(input: unknown) {
 export async function updateVehicle(input: unknown) {
   return withAuth(
     async ({ organizationId, userId }) => {
-      const { id, inspectionDueAt, inspectionDueSource, ...data } = updateVehicleSchema.parse(input)
+      const { id, inspectionDueAt, registryLookup, ...data } = updateVehicleSchema.parse(input)
       assertOwnUploads(data, organizationId)
 
       // Fetch current record for display/diff, and the image it had
       const before = await db.vehicle.findFirst({
         where: { id, organizationId },
-        select: { year: true, make: true, model: true, licensePlate: true, imageUrl: true },
+        select: {
+          year: true,
+          make: true,
+          model: true,
+          licensePlate: true,
+          vin: true,
+          imageUrl: true,
+        },
       })
       if (!before) throw new Error('Vehicle not found')
       await assertOwnedCustomer(data.customerId, organizationId)
@@ -346,7 +365,10 @@ export async function updateVehicle(input: unknown) {
       if (data.imageUrl !== undefined && before.imageUrl && before.imageUrl !== data.imageUrl) {
         await releaseFiles([before.imageUrl], { organizationId, reason: 'vehicle image replaced' })
       }
-      await saveInspectionDate(organizationId, id, inspectionDueAt, inspectionDueSource)
+      await saveInspectionDate(organizationId, id, inspectionDueAt, registryLookup, {
+        licensePlate: data.licensePlate !== undefined ? data.licensePlate : before.licensePlate,
+        vin: data.vin !== undefined ? data.vin : before.vin,
+      })
       const vehicleDisplay = `${before.year} ${before.make} ${before.model}${before.licensePlate ? ` (${before.licensePlate})` : ''}`
       const changedKeys = Object.keys(data).filter(
         (k) => (data as Record<string, unknown>)[k] !== undefined

+ 42 - 37
src/features/vehicles/Components/VehicleForm.tsx

@@ -38,6 +38,7 @@ import { toast } from 'sonner'
 import { useGlassModal } from '@/components/glass-modal'
 import { useDateSettings } from '@/components/date-settings-context'
 import { inspectionDueInput } from '../Lib/inspectionDueInput'
+import type { RegistryLookup } from '../Lib/registryLookup'
 import { createVehicle, updateVehicle } from '../Actions/vehicleActions'
 import type { VehicleDocumentScan } from '../Actions/aiAnalyzeVehicleDocument'
 import { ScanDocumentButton } from './ScanDocumentButton'
@@ -132,10 +133,16 @@ export function VehicleForm({
   const [transmission, setTransmission] = useState(defaultTransmission)
   /** What the registry said about the plate, kept under the field until the dialog closes. */
   const [lookupNote, setLookupNote] = useState<VehicleLookup | null>(null)
-  // The inspection date a lookup wrote into the form, and which registry
-  // said so. Sent with the save while the field still holds that value, so
-  // the date is stored as the registry's answer and not looked up again.
-  const lookupDateRef = useRef<{ value: string; connectorId: string } | null>(null)
+  // The registry lookup made on this form, sent with the save so the answer
+  // is stored as the registry's, checked now, and not looked up again. The
+  // server keeps it only while the plate or VIN it was about is still the one saved.
+  const lookupRef = useRef<RegistryLookup | null>(null)
+  /** The inspection date the form opened with; a field still holding it was not edited. */
+  const openedInspectionDue = vehicle?.inspectionStatus?.dueAt
+    ? inspectionDueInput(vehicle.inspectionStatus.dueAt, vehicle.inspectionStatus.source, timezone)
+    : ''
+  const fieldValue = (name: string) =>
+    (formRef.current?.elements.namedItem(name) as HTMLInputElement | null)?.value ?? ''
   /** Keeper read off a scanned document, until it is tied to a customer. */
   const [scannedOwner, setScannedOwner] = useState<{ name?: string; address?: string } | null>(null)
   const [ownerMatches, setOwnerMatches] = useState<OwnerMatch[]>([])
@@ -268,7 +275,7 @@ export function VehicleForm({
    * gearbox and engine code, which papers rarely state. The select only moves while it still
    * holds the value the form opened with.
    */
-  const applyLookup = (data: VehicleLookup) => {
+  const applyLookup = (data: VehicleLookup, asked: { by: 'plate' | 'vin'; value: string }) => {
     applyScan(data)
     const engineCode = formRef.current?.elements.namedItem('engineCode') as HTMLInputElement | null
     if (data.engineCode && engineCode && !engineCode.value) engineCode.value = data.engineCode
@@ -276,11 +283,23 @@ export function VehicleForm({
       const input = formRef.current?.elements.namedItem(
         'inspectionDueAt'
       ) as HTMLInputElement | null
-      if (input && !input.value) {
+      // The registry's fresh date replaces an empty field, the date the form
+      // opened with, or the date an earlier lookup wrote; a date the user
+      // typed since stays.
+      const earlier = lookupRef.current?.result.inspectionDue?.slice(0, 10)
+      if (
+        input &&
+        (!input.value || input.value === openedInspectionDue || input.value === earlier)
+      ) {
         input.value = data.inspectionDue.slice(0, 10)
-        lookupDateRef.current = { value: input.value, connectorId: data.connectorId }
       }
     }
+    lookupRef.current = {
+      connectorId: data.connectorId,
+      by: asked.by,
+      value: asked.value,
+      result: data,
+    }
     if (data.transmission && transmission === defaultTransmission) {
       setTransmission(data.transmission)
     }
@@ -294,7 +313,9 @@ export function VehicleForm({
     if (!open || vehicle || !initialLookup) return
     if (appliedLookupRef.current === initialLookup) return
     appliedLookupRef.current = initialLookup
-    const frame = requestAnimationFrame(() => applyLookup(initialLookup))
+    const frame = requestAnimationFrame(() =>
+      applyLookup(initialLookup, { by: 'plate', value: initialLookup.licensePlate ?? '' })
+    )
     return () => cancelAnimationFrame(frame)
     // applyLookup reads the current form and selects; running it again on
     // their change would overwrite what the user has since typed.
@@ -303,7 +324,7 @@ export function VehicleForm({
   useEffect(() => {
     if (!open) {
       appliedLookupRef.current = null
-      lookupDateRef.current = null
+      lookupRef.current = null
     }
   }, [open])
 
@@ -365,6 +386,7 @@ export function VehicleForm({
       // touched" and would bring the old value back. On create, leaving an
       // empty field out is the same thing as clearing it.
       const optional = (name: string) => clearableInput(formData.get(name), Boolean(vehicle))
+      const inspectionDue = (formData.get('inspectionDueAt') as string | null) ?? ''
       const withUnit = <T,>(value: T) =>
         typeof value === 'string' && !isMarine
           ? engineSizeToSave(value, vehicle?.engineSize)
@@ -384,12 +406,12 @@ export function VehicleForm({
         engineCode: optional('engineCode'),
         hsn: showTypeKey ? optional('hsn') : undefined,
         tsn: showTypeKey ? optional('tsn') : undefined,
-        // Sent as-is: an empty string clears a hand-typed date, undefined leaves it alone.
-        inspectionDueAt: isMarine ? undefined : ((formData.get('inspectionDueAt') as string) ?? ''),
-        inspectionDueSource:
-          !isMarine && lookupDateRef.current?.value === formData.get('inspectionDueAt')
-            ? lookupDateRef.current.connectorId
-            : undefined,
+        // An empty string clears a hand-typed date; undefined leaves the stored
+        // date alone, so saving another field, or a lookup that brought no
+        // date, does not turn a registry's date into one typed by hand.
+        inspectionDueAt:
+          isMarine || inspectionDue === openedInspectionDue ? undefined : inspectionDue,
+        registryLookup: isMarine ? undefined : (lookupRef.current ?? undefined),
         customerId,
       }
 
@@ -666,14 +688,8 @@ export function VehicleForm({
                     {!isMarine && (
                       <VehicleLookupButton
                         by="plate"
-                        getValue={() =>
-                          (
-                            formRef.current?.elements.namedItem(
-                              'licensePlate'
-                            ) as HTMLInputElement | null
-                          )?.value ?? ''
-                        }
-                        onFound={applyLookup}
+                        getValue={() => fieldValue('licensePlate')}
+                        onFound={(data, asked) => applyLookup(data, { by: 'plate', value: asked })}
                         vehicleId={vehicle?.id}
                       />
                     )}
@@ -692,11 +708,8 @@ export function VehicleForm({
                     {!isMarine && (
                       <VehicleLookupButton
                         by="vin"
-                        getValue={() =>
-                          (formRef.current?.elements.namedItem('vin') as HTMLInputElement | null)
-                            ?.value ?? ''
-                        }
-                        onFound={applyLookup}
+                        getValue={() => fieldValue('vin')}
+                        onFound={(data, asked) => applyLookup(data, { by: 'vin', value: asked })}
                         vehicleId={vehicle?.id}
                       />
                     )}
@@ -844,15 +857,7 @@ export function VehicleForm({
                     id="inspectionDueAt"
                     name="inspectionDueAt"
                     type="date"
-                    defaultValue={
-                      vehicle?.inspectionStatus?.dueAt
-                        ? inspectionDueInput(
-                            vehicle.inspectionStatus.dueAt,
-                            vehicle.inspectionStatus.source,
-                            timezone
-                          )
-                        : ''
-                    }
+                    defaultValue={openedInspectionDue}
                   />
                   <p className="text-xs text-muted-foreground">
                     {vehicle?.inspectionStatus?.source &&

+ 13 - 4
src/features/vehicles/Components/VehicleLookupButton.tsx

@@ -17,8 +17,12 @@ interface VehicleLookupButtonProps {
   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. */
-  onFound: (data: VehicleLookup) => void
+  /**
+   * Called with what the registry knows, for the form to apply, and the
+   * plate or VIN it was asked about: read when the button was pressed, not
+   * when the answer came, in case the field was retyped meanwhile.
+   */
+  onFound: (data: VehicleLookup, asked: string) => void
   /** Set when editing, so the answer is also recorded on the vehicle. */
   vehicleId?: string
 }
@@ -33,7 +37,12 @@ interface VehicleLookupButtonProps {
  * registry, so a workshop learns one can be connected; the VIN button only
  * appears once something that decodes VINs is.
  */
-export function VehicleLookupButton({ by, getValue, onFound, vehicleId }: VehicleLookupButtonProps) {
+export function VehicleLookupButton({
+  by,
+  getValue,
+  onFound,
+  vehicleId,
+}: VehicleLookupButtonProps) {
   const t = useTranslations('vehicles.form')
   const [busy, setBusy] = useState(false)
   /** null while the availability check is still in flight. */
@@ -69,7 +78,7 @@ export function VehicleLookupButton({ by, getValue, onFound, vehicleId }: Vehicl
         toast.error(t(by === 'vin' ? 'lookupVinNotFound' : 'lookupNotFound'), { id: toastId })
         return
       }
-      onFound(result.data)
+      onFound(result.data, value)
       toast.success(t('lookupSuccess', { source: result.data.source }), { id: toastId })
     } catch {
       toast.error(t(by === 'vin' ? 'lookupVinFailed' : 'lookupFailed'), { id: toastId })

+ 72 - 0
src/features/vehicles/Lib/registryLookup.ts

@@ -0,0 +1,72 @@
+import { z } from 'zod'
+
+/**
+ * A registry's answer as the vehicle form hands it back on save: which
+ * connector answered, what it was asked, and the parts of the answer the
+ * inspection status keeps. Saved with `recordRegistryAnswer`, so a vehicle
+ * created or edited from a lookup is stored as checked now, with the day the
+ * registry gave, and the hourly sync does not spend a second lookup on it.
+ */
+export const registryAnswerSchema = z.object({
+  inspectionDue: z.string().max(40).optional(),
+  lastInspected: z.string().max(40).optional(),
+  registered: z.boolean().optional(),
+  vehicleClass: z.string().max(120).optional(),
+  firstRegistered: z.string().max(40).optional(),
+  tyres: z
+    .array(
+      z.object({
+        axle: z.number(),
+        tyre: z.string().max(40).optional(),
+        rim: z.string().max(40).optional(),
+        loadIndex: z.string().max(20).optional(),
+        speedRating: z.string().max(20).optional(),
+      })
+    )
+    .max(10)
+    .optional(),
+  weights: z.object({ kerb: z.number().optional(), grossMax: z.number().optional() }).optional(),
+})
+
+export const registryLookupSchema = z.object({
+  connectorId: z.string().max(60),
+  /** What the registry was asked about, so the answer is only kept for that vehicle. */
+  by: z.enum(['plate', 'vin']),
+  value: z.string().max(32),
+  result: registryAnswerSchema,
+})
+
+export type RegistryLookup = z.infer<typeof registryLookupSchema>
+
+/** A plate or VIN reduced to what identifies it: letters and digits, upper case. */
+export function identifierKey(value: string): string {
+  return value.toUpperCase().replace(/[^A-Z0-9]/g, '')
+}
+
+/**
+ * Whether the lookup was about the vehicle as it is being saved. A plate
+ * retyped after the lookup is another vehicle, whose answer this is not.
+ */
+export function lookupAppliesTo(
+  lookup: Pick<RegistryLookup, 'by' | 'value'>,
+  vehicle: { licensePlate?: string | null; vin?: string | null }
+): boolean {
+  const saved = lookup.by === 'vin' ? vehicle.vin : vehicle.licensePlate
+  if (!saved) return false
+  const asked = identifierKey(lookup.value)
+  return asked.length > 0 && asked === identifierKey(saved)
+}
+
+/**
+ * Whether the form's date field still says what the registry said: left
+ * alone, or holding the day the registry gave. Anything else, a cleared
+ * field included, is the workshop's own doing.
+ */
+export function holdsRegistryDate(
+  value: string | undefined,
+  result: Pick<RegistryLookup['result'], 'inspectionDue'>
+): boolean {
+  if (value === undefined) return true
+  const registryDay = result.inspectionDue?.slice(0, 10)
+  return Boolean(registryDay) && value.trim() === registryDay
+}

+ 10 - 5
src/features/vehicles/Schema/vehicleSchema.ts

@@ -1,5 +1,6 @@
 import { z } from 'zod'
 import { optionalUploadUrlSchema } from '@/lib/upload-url'
+import { registryLookupSchema } from '../Lib/registryLookup'
 import { HSN_PATTERN, normalizeHsn, normalizeTsn, TSN_PATTERN } from '../Lib/typeKey'
 
 // An empty string passes: on an update it is how a cleared field says so.
@@ -33,14 +34,18 @@ export const createVehicleSchema = z.object({
   purchasePrice: z.coerce.number().optional(),
   imageUrl: optionalUploadUrlSchema.optional(),
   customerId: z.string().optional(),
-  /** Next periodic inspection, YYYY-MM-DD, set by hand. Empty clears a manual date. */
+  /**
+   * Next periodic inspection, YYYY-MM-DD, as the form's field holds it. Empty
+   * clears a manual date; undefined leaves the stored date alone.
+   */
   inspectionDueAt: z.string().optional(),
   /**
-   * The registry connector whose lookup filled inspectionDueAt, left as it
-   * was. The date is then saved as that registry's answer, checked now, and
-   * not as a hand-typed date the sync would look up again straight away.
+   * The registry lookup made on this form, when there was one. Saved as that
+   * registry's answer, checked now, so the sync does not look the vehicle up
+   * again straight away; a date typed over the registry's is saved by hand
+   * on top of it.
    */
-  inspectionDueSource: z.string().max(60).optional(),
+  registryLookup: registryLookupSchema.optional(),
 })
 
 export const updateVehicleSchema = createVehicleSchema.partial().extend({