4
0
Эх сурвалжийг харах

Move a job to In Progress when work starts on it (#545)

* Move a job to In Progress when work starts on it

* Only move a job that is still not started when the clock writes
Bernt Christian Egeland 23 цаг өмнө
parent
commit
46070b3774

+ 8 - 2
e2e/specs/tech/api.spec.ts

@@ -286,9 +286,15 @@ test.describe('the technician app', () => {
     expect(entries.status()).toBe(200)
     expect(entries.status()).toBe(200)
     expect(JSON.stringify(await entries.json())).toContain(jobId)
     expect(JSON.stringify(await entries.json())).toContain(jobId)
 
 
-    // The job list now says the clock is on it, which is what draws the bar.
+    // The job list now says the clock is on it, which is what draws the bar,
+    // and the job it had not started yet is In Progress.
     const running = await phone(device).get('/api/v1/tech/jobs')
     const running = await phone(device).get('/api/v1/tech/jobs')
-    expect((await running.json()).data.openEntryJobId).toBe(jobId)
+    const runningData = (await running.json()).data
+    expect(runningData.openEntryJobId).toBe(jobId)
+    expect(
+      runningData.jobs.find((job: { id: string }) => job.id === jobId)?.status,
+      'starting work moved the job along'
+    ).toBe('in-progress')
 
 
     const stopped = await phone(device).post('/api/v1/tech/time/stop')
     const stopped = await phone(device).post('/api/v1/tech/time/stop')
     expect(stopped.status()).toBe(200)
     expect(stopped.status()).toBe(200)

+ 134 - 0
src/__tests__/features/time-tracking/start-work-moves-job.test.ts

@@ -0,0 +1,134 @@
+/**
+ * @vitest-environment node
+ *
+ * Starting the clock on a job that has not been started moves it to In
+ * Progress. The app's button says "Start work", and a running clock under a
+ * stage that still read "Not started" told the desk the opposite of what was
+ * happening in the bay.
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+// status: what the clock's first read sees; current: the row by the time the write runs, when the desk
+// changed it in between.
+const state = { status: 'pending' as string, current: null as string | null }
+
+vi.mock('@/lib/db', () => {
+  const tx = {
+    timeEntry: {
+      update: vi.fn(),
+      create: vi.fn(async () => ({
+        id: 'entry-1',
+        startedAt: new Date(),
+        technicianId: 'tech-1',
+        serviceRecordId: 'job-1',
+        serviceRecord: { id: 'job-1', title: 'Brake pads', status: state.current ?? state.status },
+      })),
+    },
+    serviceRecord: {
+      // Moves the row only while it is still in a stage the write names, as the database would.
+      updateMany: vi.fn(
+        async ({
+          where,
+          data,
+        }: {
+          where: { status: { in: string[] } }
+          data: { status: string }
+        }) => {
+          const current = state.current ?? state.status
+          if (!where.status.in.includes(current)) return { count: 0 }
+          state.status = data.status
+          return { count: 1 }
+        }
+      ),
+    },
+  }
+  return {
+    db: {
+      serviceRecord: {
+        findFirst: vi.fn(async () => ({
+          id: 'job-1',
+          status: state.status,
+          title: 'Brake pads',
+          vehicle: {
+            id: 'veh-1',
+            make: 'Honda',
+            model: 'Civic',
+            year: 2020,
+            licensePlate: 'NC-6677',
+          },
+        })),
+      },
+      timeEntry: { findFirst: vi.fn(async () => null) },
+      $transaction: vi.fn(async (run: (t: typeof tx) => Promise<unknown>) => run(tx)),
+      __tx: tx,
+    },
+  }
+})
+
+import { db } from '@/lib/db'
+import { notificationBus } from '@/lib/notification-bus'
+import { startEntry } from '@/features/time-tracking/Lib/timeEntries'
+
+const tx = (db as unknown as { __tx: { serviceRecord: { updateMany: ReturnType<typeof vi.fn> } } })
+  .__tx
+
+let heard: { type: string; status?: string }[]
+beforeEach(() => {
+  vi.clearAllMocks()
+  state.current = null
+  heard = []
+  notificationBus.removeAllListeners('workboard')
+  notificationBus.on('workboard', (event: { type: string; status?: string }) => heard.push(event))
+})
+
+const start = () =>
+  startEntry({
+    organizationId: 'org-1',
+    technicianId: 'tech-1',
+    technicianIds: ['tech-1'],
+    serviceRecordId: 'job-1',
+  })
+
+describe('starting work on a job', () => {
+  it.each([
+    'pending',
+    'scheduled',
+  ])('moves a %s job to In Progress and tells the desk', async (from) => {
+    state.status = from
+    const { entry } = await start()
+
+    expect(tx.serviceRecord.updateMany).toHaveBeenCalledWith(
+      expect.objectContaining({
+        where: { id: 'job-1', organizationId: 'org-1', status: { in: ['pending', 'scheduled'] } },
+        // A status of the workshop's own belonged to the stage it left.
+        data: { status: 'in-progress', customStatusId: null, customStatusSince: null },
+      })
+    )
+    expect(entry.serviceRecord.status).toBe('in-progress')
+    expect(heard).toContainEqual(
+      expect.objectContaining({ type: 'job_status_changed', status: 'in-progress' })
+    )
+  })
+
+  it.each([
+    'in-progress',
+    'waiting-parts',
+    'completed',
+  ])('leaves a job that is already %s where it is', async (from) => {
+    state.status = from
+    await start()
+
+    expect(tx.serviceRecord.updateMany).not.toHaveBeenCalled()
+    expect(heard.map((e) => e.type)).toEqual(['clock_started'])
+  })
+
+  it('leaves a job the desk moved on since the clock read it', async () => {
+    // Pending when the clock looked, finished by the desk before it wrote.
+    state.status = 'pending'
+    state.current = 'completed'
+    await start()
+
+    expect(state.current).toBe('completed')
+    expect(heard.map((e) => e.type)).toEqual(['clock_started'])
+  })
+})

+ 45 - 3
src/features/time-tracking/Lib/timeEntries.ts

@@ -1,5 +1,7 @@
 import { db } from '@/lib/db'
 import { db } from '@/lib/db'
 import { notificationBus } from '@/lib/notification-bus'
 import { notificationBus } from '@/lib/notification-bus'
+import { announceJobStatusChanged } from '@/features/vehicles/Lib/jobEvents'
+import { statusColumns } from '@/features/work-order-statuses/Lib/stages'
 
 
 /**
 /**
  * Clocking a technician on and off a job.
  * Clocking a technician on and off a job.
@@ -110,6 +112,11 @@ export async function loggedMinutes(serviceRecordId: string): Promise<number> {
  * really a product rule. A technician who taps start on a second job has
  * really a product rule. A technician who taps start on a second job has
  * moved on from the first, so the open one is closed rather than refused.
  * moved on from the first, so the open one is closed rather than refused.
  * Refusing would leave them staring at an error in a bay with oily gloves.
  * Refusing would leave them staring at an error in a bay with oily gloves.
+ *
+ * A job that has not been started yet moves to In Progress with the clock.
+ * The app's button says "Start work", and a job whose clock is running while
+ * its stage still reads "Not started" tells the desk the opposite of what is
+ * happening in the bay. A job already further along is left where it is.
  */
  */
 export async function startEntry(args: {
 export async function startEntry(args: {
   organizationId: string
   organizationId: string
@@ -122,7 +129,13 @@ export async function startEntry(args: {
 
 
   const job = await db.serviceRecord.findFirst({
   const job = await db.serviceRecord.findFirst({
     where: { id: serviceRecordId, organizationId },
     where: { id: serviceRecordId, organizationId },
-    select: { id: true },
+    select: {
+      id: true,
+      status: true,
+      title: true,
+      // Enough for the board to redraw the card, as the app's own status change sends.
+      vehicle: { select: { id: true, make: true, model: true, year: true, licensePlate: true } },
+    },
   })
   })
   if (!job) {
   if (!job) {
     throw new TimeEntryError('job_not_found', 'That job does not exist in this workshop.')
     throw new TimeEntryError('job_not_found', 'That job does not exist in this workshop.')
@@ -149,6 +162,19 @@ export async function startEntry(args: {
         })
         })
       }
       }
 
 
+      // Before the entry, so the job it returns already reads In Progress. The
+      // stage changes, so a status of the workshop's own goes with it, as when
+      // the app moves the stage itself. Conditional on the stage it has now,
+      // not the one read above: the desk may have finished the job or marked
+      // it waiting for parts since, and that is not this tap's to undo.
+      const moved = NOT_STARTED.has(job.status)
+        ? await tx.serviceRecord.updateMany({
+            where: { id: job.id, organizationId, status: { in: [...NOT_STARTED] } },
+            data: statusColumns('in-progress'),
+          })
+        : { count: 0 }
+      const started = moved.count > 0
+
       const entry = await tx.timeEntry.create({
       const entry = await tx.timeEntry.create({
         data: {
         data: {
           organizationId,
           organizationId,
@@ -166,7 +192,7 @@ export async function startEntry(args: {
         },
         },
       })
       })
 
 
-      return { entry, closed }
+      return { entry, closed, started }
     })
     })
     .then((result) => {
     .then((result) => {
       // After the commit, never inside it: a listener that refetches on the
       // After the commit, never inside it: a listener that refetches on the
@@ -187,10 +213,26 @@ export async function startEntry(args: {
         serviceRecordId,
         serviceRecordId,
         entryId: result.entry.id,
         entryId: result.entry.id,
       })
       })
-      return result
+      if (result.started) {
+        announceJobStatusChanged({
+          organizationId,
+          serviceRecordId: job.id,
+          status: 'in-progress',
+          serviceRecord: {
+            id: job.id,
+            title: job.title,
+            status: 'in-progress',
+            vehicle: job.vehicle,
+          },
+        })
+      }
+      return { entry: result.entry, closed: result.closed }
     })
     })
 }
 }
 
 
+/** Stages a job sits in before anybody has worked on it: listed, or booked for a day. */
+const NOT_STARTED = new Set(['pending', 'scheduled'])
+
 /** Stop whatever is running. Idempotent: stopping nothing is not an error the app should have to handle twice. */
 /** Stop whatever is running. Idempotent: stopping nothing is not an error the app should have to handle twice. */
 export async function stopEntry(args: {
 export async function stopEntry(args: {
   organizationId: string
   organizationId: string