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

Open a work order made from a portal request with the customer's words, on the day they asked for (#523)

* Open a work order made from a portal request with the customer's words, on the day they asked for

* Look for a free slot with the technician the job will get, the default one included
Bernt Christian Egeland 3 дней назад
Родитель
Сommit
81d4fd73cb

+ 71 - 0
src/__tests__/features/customers/request-to-work-order.test.ts

@@ -0,0 +1,71 @@
+/**
+ * Turning a customer's portal request into a work order. The job should open
+ * with the customer's own words as its first concern, booked on the day they
+ * asked for: it used to open with an empty job description, booked at midnight.
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/lib/cached-session', () => ({
+  getCachedSession: vi.fn(),
+  getCachedMembership: vi.fn(),
+}))
+vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
+vi.mock('@/features/vehicles/Actions/createDraftServiceRecord', () => ({
+  createDraftServiceRecord: vi.fn(async () => ({ success: true, data: { id: 'job-1' } })),
+}))
+vi.mock('@/lib/db', () => ({
+  db: {
+    user: { findUnique: vi.fn() },
+    serviceRequest: { findFirst: vi.fn(), update: vi.fn() },
+    serviceRecord: { update: vi.fn() },
+  },
+}))
+
+import { getCachedMembership, getCachedSession } from '@/lib/cached-session'
+import { db } from '@/lib/db'
+import { createDraftServiceRecord } from '@/features/vehicles/Actions/createDraftServiceRecord'
+import { createWorkOrderFromRequest } from '@/features/customers/Actions/customerActions'
+
+const WORDS = 'Squealing from the rear brakes when I stop, mostly in the mornings.'
+const ASKED = new Date('2026-11-16T06:00:00Z')
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  vi.mocked(getCachedSession).mockResolvedValue({
+    user: { id: 'u1', email: 'a@example.com' },
+  } as never)
+  vi.mocked(getCachedMembership).mockResolvedValue({
+    organizationId: 'org1',
+    role: 'owner',
+    roleId: null,
+    customRole: null,
+  } as never)
+  vi.mocked(db.user.findUnique).mockResolvedValue({ isSuperAdmin: false, name: 'Alex' } as never)
+  vi.mocked(db.serviceRequest.findFirst).mockResolvedValue({
+    id: 'req-1',
+    status: 'pending',
+    description: WORDS,
+    preferredDate: ASKED,
+    vehicleId: 'veh-1',
+    adminNotes: null,
+    vehicle: { id: 'veh-1' },
+  } as never)
+})
+
+describe('a portal request turned into a work order', () => {
+  it("opens with the customer's words as the first concern", async () => {
+    const result = await createWorkOrderFromRequest('req-1')
+    expect(result.success).toBe(true)
+    const update = vi.mocked(db.serviceRecord.update).mock.calls[0][0] as {
+      data: { concerns?: { create: { description: string } } }
+    }
+    expect(update.data.concerns?.create.description).toBe(WORDS)
+  })
+
+  it('is booked from the day the customer asked for, not with that day as its start time', async () => {
+    await createWorkOrderFromRequest('req-1')
+    const args = vi.mocked(createDraftServiceRecord).mock.calls[0] as unknown[]
+    expect(args[1]).toBeUndefined()
+    expect(args[5]).toEqual(ASKED)
+  })
+})

+ 81 - 0
src/__tests__/features/workorders/create-workorder.test.ts

@@ -718,6 +718,87 @@ describe('createDraftServiceRecord — scheduling', () => {
     vi.useRealTimers()
   })
 
+  /**
+   * A customer asking for a day through the portal names a day, not a time.
+   * The day used to be passed as the start time, so the job was booked from
+   * midnight to one in the morning. It now takes the first free slot that day.
+   */
+  it('books a day the customer asked for from its opening, not at midnight', async () => {
+    vi.useFakeTimers()
+    // Tuesday 1 September, 11:00; the customer asked for Monday 7 September.
+    vi.setSystemTime(new Date(Date.UTC(2026, 8, 1, 11, 0, 0)))
+    setupAuth()
+    vi.mocked(db.vehicle.findFirst).mockResolvedValue(VEHICLE as any)
+    vi.mocked(db.appSetting.findMany).mockResolvedValue(SHOP_HOURS)
+    vi.mocked(db.organization.findUnique).mockResolvedValue({ name: 'Shop' } as any)
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(null)
+    vi.mocked(db.serviceRecord.create).mockResolvedValue({ id: 'sr-asked' } as any)
+
+    const asked = new Date(Date.UTC(2026, 8, 7, 0, 0, 0))
+    await createDraftServiceRecord(VEHICLE_ID, undefined, undefined, undefined, undefined, asked)
+
+    const createCall = vi.mocked(db.serviceRecord.create).mock.calls[0][0] as any
+    const startDT: Date = createCall.data.startDateTime
+    expect(startDT.toISOString()).toBe('2026-09-07T08:30:00.000Z')
+    vi.useRealTimers()
+  })
+
+  it("does not book over the default technician's job on the asked-for day", async () => {
+    vi.useFakeTimers()
+    vi.setSystemTime(new Date(Date.UTC(2026, 8, 1, 11, 0, 0)))
+    setupAuth()
+    vi.mocked(db.vehicle.findFirst).mockResolvedValue(VEHICLE as any)
+    vi.mocked(db.appSetting.findMany).mockResolvedValue([
+      ...SHOP_HOURS,
+      { key: 'workshop.defaultTechnician', value: 'Default Tech' },
+    ])
+    vi.mocked(db.organization.findUnique).mockResolvedValue({ name: 'Shop' } as any)
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(null)
+    vi.mocked(db.technician.findFirst)
+      .mockResolvedValueOnce({ id: 'default-tech-id', name: 'Default Tech' } as any)
+      .mockResolvedValue({ name: 'Default Tech' } as any)
+    // The default technician already has the morning of Monday 7 September.
+    vi.mocked(db.serviceRecord.findMany).mockResolvedValue([
+      {
+        id: 'busy',
+        title: 'Clutch',
+        startDateTime: new Date(Date.UTC(2026, 8, 7, 8, 30)),
+        endDateTime: new Date(Date.UTC(2026, 8, 7, 11, 0)),
+        technicianId: 'default-tech-id',
+        workBayId: null,
+        vehicle: null,
+      },
+    ] as any)
+    vi.mocked(db.serviceRecord.create).mockResolvedValue({ id: 'sr-default-busy' } as any)
+
+    const asked = new Date(Date.UTC(2026, 8, 7, 0, 0, 0))
+    await createDraftServiceRecord(VEHICLE_ID, undefined, undefined, undefined, undefined, asked)
+
+    const createCall = vi.mocked(db.serviceRecord.create).mock.calls[0][0] as any
+    expect(createCall.data.technicianId).toBe('default-tech-id')
+    expect(createCall.data.startDateTime.toISOString()).toBe('2026-09-07T11:00:00.000Z')
+    vi.useRealTimers()
+  })
+
+  it('ignores an asked-for day that has already gone and books the next free slot', async () => {
+    vi.useFakeTimers()
+    vi.setSystemTime(new Date(Date.UTC(2026, 8, 1, 11, 0, 0)))
+    setupAuth()
+    vi.mocked(db.vehicle.findFirst).mockResolvedValue(VEHICLE as any)
+    vi.mocked(db.appSetting.findMany).mockResolvedValue(SHOP_HOURS)
+    vi.mocked(db.organization.findUnique).mockResolvedValue({ name: 'Shop' } as any)
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(null)
+    vi.mocked(db.serviceRecord.create).mockResolvedValue({ id: 'sr-past' } as any)
+
+    const past = new Date(Date.UTC(2026, 7, 20, 0, 0, 0))
+    await createDraftServiceRecord(VEHICLE_ID, undefined, undefined, undefined, undefined, past)
+
+    const createCall = vi.mocked(db.serviceRecord.create).mock.calls[0][0] as any
+    const startDT: Date = createCall.data.startDateTime
+    expect(startDT.toISOString().slice(0, 13)).toBe('2026-09-01T11')
+    vi.useRealTimers()
+  })
+
   it('steps past a booking the technician already has', async () => {
     vi.useFakeTimers()
     vi.setSystemTime(new Date(Date.UTC(2026, 8, 1, 6, 0, 0)))

+ 14 - 2
src/features/customers/Actions/customerActions.ts

@@ -467,9 +467,17 @@ export async function createWorkOrderFromRequest(requestId: string) {
         throw new Error('Work order already created for this request')
 
       const vehicleId = request.vehicleId
-      const serviceDate = request.preferredDate ?? undefined
 
-      const result = await createDraftServiceRecord(vehicleId, serviceDate)
+      // The customer named a day, not a time: book the first free slot from
+      // that day on. Passed as the start time, it booked the job at midnight.
+      const result = await createDraftServiceRecord(
+        vehicleId,
+        undefined,
+        undefined,
+        undefined,
+        undefined,
+        request.preferredDate ?? undefined
+      )
       if (!result.success || !result.data) {
         throw new Error(result.error ?? 'Failed to create work order')
       }
@@ -485,6 +493,10 @@ export async function createWorkOrderFromRequest(requestId: string) {
         data: {
           title: truncatedTitle,
           description: request.description,
+          // The job description is told as concerns, the first step of which
+          // is the customer's own words. Without one the job opened with
+          // "Not written yet" and the request text only in the cut-short title.
+          concerns: { create: { description: request.description, sortOrder: 0 } },
         },
       })
 

+ 4 - 1
src/features/vehicles/Actions/createDraftServiceRecord.ts

@@ -10,7 +10,9 @@ export async function createDraftServiceRecord(
   startDateTime?: Date,
   endDateTime?: Date,
   technicianId?: string,
-  workBayId?: string
+  workBayId?: string,
+  /** The day the job is wanted, when no time was given; see createDraftRecord. */
+  bookFrom?: Date
 ) {
   return withAuth(
     async ({ organizationId, userId }) => {
@@ -32,6 +34,7 @@ export async function createDraftServiceRecord(
           endDateTime,
           technicianId,
           workBayId,
+          bookFrom,
         }
       )
     },

+ 16 - 4
src/features/vehicles/Lib/createDraftRecord.ts

@@ -53,6 +53,14 @@ export async function createDraftRecord(
     technicianId?: string
     /** Bay the job was booked into, when it was created from the work board. */
     workBayId?: string
+    /**
+     * The day the job is wanted, when a day was asked for but no time: a
+     * customer's preferred date on a portal request. The first free slot is
+     * looked for from that day on, rather than from now. A day already past is
+     * ignored. Passing the day itself as startDateTime booked the job at
+     * midnight.
+     */
+    bookFrom?: Date
     /**
      * The transaction the record is written in, when the caller has more to
      * write that must land with it or not at all. Reads stay on the pool.
@@ -200,13 +208,17 @@ export async function createDraftRecord(
       opts.startDateTime ??
       atZonedTime(now, settingsMap['workboard.workDayStart'] || '07:00', timeZone)
   } else {
-    const { bookings, hours } = await loadBookingContext(organizationId, now)
+    const from = opts.bookFrom && opts.bookFrom.getTime() > now.getTime() ? opts.bookFrom : now
+    const { bookings, hours } = await loadBookingContext(organizationId, from)
     const slot = nextAvailableSlot({
-      from: now,
+      from,
       durationMinutes: DEFAULT_JOB_MINUTES,
       bookings,
       hours,
-      technicianId: opts.technicianId,
+      // Whoever the job will be assigned to, the default technician included:
+      // searching only for one passed in let a default technician's day be
+      // double-booked.
+      technicianId: resolvedTechId || undefined,
       workBayId: opts.workBayId,
     })
     if (slot) {
@@ -215,7 +227,7 @@ export async function createDraftRecord(
     } else {
       // Booked solid for weeks. Falling back to opening time keeps the job on
       // the board rather than refusing to create it over a scheduling detail.
-      defaultStart = atZonedTime(now, hours.start || '07:00', timeZone)
+      defaultStart = atZonedTime(from, hours.start || '07:00', timeZone)
     }
   }
   // serviceDate should be date-only (start of the workshop's day)