movements.spec.ts 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  1. import { expect, test } from '@playwright/test'
  2. import {
  3. ownerOrganizationId,
  4. partQuantity,
  5. setPartQuantity,
  6. type StockedPart,
  7. stockedPart,
  8. stockMovements,
  9. } from '../../support/db'
  10. import { settle } from '../../support/hydration'
  11. import { addPartFromInventory, setPartQuantityField } from '../../support/inventory'
  12. import { newWorkOrder, partRows, saveWorkOrder, seededVehicleUrl } from '../../support/work-order'
  13. /**
  14. * A part fitted to a car leaves the shelf exactly once.
  15. *
  16. * Stock is the one number in this app that a customer never sees and a
  17. * workshop counts by hand, so an error in it is discovered weeks later at a
  18. * stocktake with no way to tell which job caused it. The rules are unit
  19. * tested (`reconcileStock` computes a net delta per part), but the risk is not
  20. * in the arithmetic: it is in how often the arithmetic runs. A save that
  21. * re-applies the whole set decrements twice; a save the editor refuses must
  22. * decrement nothing at all; and the ledger has to agree with the count,
  23. * because the count is only the running total of the ledger.
  24. *
  25. * Everything is asserted against both: the balance on the part and the rows
  26. * in `stock_movements`.
  27. */
  28. test.describe.configure({ mode: 'serial' })
  29. const stamp = Date.now()
  30. let part: StockedPart
  31. /** What the part had on hand before this file touched it. */
  32. let opening = 0
  33. let jobUrl = ''
  34. let jobId = ''
  35. test.beforeAll(async () => {
  36. const organizationId = await ownerOrganizationId()
  37. part = await stockedPart(organizationId)
  38. opening = part.quantity
  39. })
  40. test.afterAll(async () => {
  41. // The count is shared with every other spec that prices a part, so it goes
  42. // back to exactly what it was even if an assertion above stopped early.
  43. if (part) await setPartQuantity(part.id, opening)
  44. })
  45. test.describe('a stocked part used on a job', () => {
  46. test('comes off the shelf once when the job is saved', async ({ page }) => {
  47. const vehicleUrl = await seededVehicleUrl(page)
  48. jobUrl = await newWorkOrder(page, vehicleUrl, `E2E stock ${stamp}`)
  49. jobId = jobUrl.split('/').pop() ?? ''
  50. await addPartFromInventory(page, part.name, 3)
  51. // Nothing has moved yet: the editor is a draft until it is saved.
  52. expect(await partQuantity(part.id), 'unsaved edits move no stock').toBe(opening)
  53. await saveWorkOrder(page)
  54. expect(await partQuantity(part.id)).toBe(opening - 3)
  55. const ledger = await stockMovements(part.id, jobId)
  56. expect(ledger).toHaveLength(1)
  57. expect(ledger[0].delta).toBe(-3)
  58. expect(ledger[0].quantityAfter, 'the ledger agrees with the count').toBe(opening - 3)
  59. expect(ledger[0].reason).toBe('service_record')
  60. })
  61. test('does not come off twice when the same job is saved again', async ({ page }) => {
  62. await page.goto(jobUrl)
  63. await settle(page)
  64. // A second save of an unchanged job: the same three parts are in the
  65. // payload, and the shelf must not be asked for three more.
  66. await saveWorkOrder(page)
  67. expect(await partQuantity(part.id)).toBe(opening - 3)
  68. expect(await stockMovements(part.id, jobId), 'no second movement').toHaveLength(1)
  69. })
  70. test('moves only the difference when the quantity is corrected', async ({ page }) => {
  71. await page.goto(jobUrl)
  72. await settle(page)
  73. // Three became five, so two more leave the shelf, not five.
  74. await expect(async () => {
  75. await setPartQuantityField(page, 5)
  76. await expect(
  77. partRows(page)
  78. .first()
  79. .locator('xpath=ancestor::*[.//input[@placeholder="Cost"]][1]')
  80. .locator('input[type="number"]')
  81. .first()
  82. ).toHaveValue('5', { timeout: 2_000 })
  83. }).toPass({ timeout: 30_000 })
  84. await saveWorkOrder(page)
  85. expect(await partQuantity(part.id)).toBe(opening - 5)
  86. const ledger = await stockMovements(part.id, jobId)
  87. expect(ledger).toHaveLength(2)
  88. expect(ledger[1].delta).toBe(-2)
  89. expect(ledger[1].quantityAfter).toBe(opening - 5)
  90. })
  91. test('goes back on the shelf when the row is removed', async ({ page }) => {
  92. await page.goto(jobUrl)
  93. await settle(page)
  94. await expect(async () => {
  95. await page.getByRole('button', { name: 'Delete row' }).first().click()
  96. await expect(partRows(page)).toHaveCount(0, { timeout: 2_000 })
  97. }).toPass({ timeout: 30_000 })
  98. await saveWorkOrder(page)
  99. expect(await partQuantity(part.id), 'the shelf is whole again').toBe(opening)
  100. const ledger = await stockMovements(part.id, jobId)
  101. expect(ledger).toHaveLength(3)
  102. expect(ledger[2].delta).toBe(5)
  103. expect(ledger[2].quantityAfter).toBe(opening)
  104. })
  105. test('moves nothing when the save is refused', async ({ page }) => {
  106. await page.goto(jobUrl)
  107. await settle(page)
  108. // A priced row with no name is refused, and the refusal has to happen
  109. // before anything is written: a payload that reached the server with the
  110. // nameless row dropped used to save the rest and take the stock with it.
  111. await addPartFromInventory(page, part.name, 2)
  112. await partRows(page).first().fill('')
  113. await page.getByRole('button', { name: 'Save', exact: true }).click()
  114. await expect(page.getByText('Rows without a part name are not saved.')).toBeVisible()
  115. await expect(page.getByText('Saved', { exact: true })).toHaveCount(0)
  116. expect(await partQuantity(part.id), 'a refused save moves no stock').toBe(opening)
  117. expect(await stockMovements(part.id, jobId)).toHaveLength(3)
  118. })
  119. })
  120. test.describe('using more than the shop has', () => {
  121. test('is allowed, and the count says how far it went under', async ({ page }) => {
  122. // Overselling is real information rather than an error: the part was
  123. // fitted, the shelf owes it, and clamping at zero would corrupt the count
  124. // as soon as the row is removed again.
  125. const vehicleUrl = await seededVehicleUrl(page)
  126. const oversoldUrl = await newWorkOrder(page, vehicleUrl, `E2E oversell ${stamp}`)
  127. const oversoldId = oversoldUrl.split('/').pop() ?? ''
  128. await addPartFromInventory(page, part.name, opening + 2)
  129. await saveWorkOrder(page)
  130. expect(await partQuantity(part.id)).toBe(-2)
  131. const ledger = await stockMovements(part.id, oversoldId)
  132. expect(ledger[0].quantityAfter).toBe(-2)
  133. // And the picker says so, in the words the workshop reads: not "in
  134. // stock", but what it now owes.
  135. await page.goto(oversoldUrl)
  136. await settle(page)
  137. const dialog = page.getByRole('dialog', { name: 'Select Part from Inventory' })
  138. await expect(async () => {
  139. await page.getByRole('button', { name: 'From Inventory', exact: true }).click()
  140. await expect(dialog).toBeVisible({ timeout: 2_000 })
  141. }).toPass({ timeout: 30_000 })
  142. await dialog.getByPlaceholder('Search inventory...').fill(part.name)
  143. await expect(dialog.getByText('On backorder (2)')).toBeVisible()
  144. await page.keyboard.press('Escape')
  145. // Put it back: the same row removed restocks everything it took.
  146. await expect(async () => {
  147. await page.getByRole('button', { name: 'Delete row' }).first().click()
  148. await expect(partRows(page)).toHaveCount(0, { timeout: 2_000 })
  149. }).toPass({ timeout: 30_000 })
  150. await saveWorkOrder(page)
  151. expect(await partQuantity(part.id)).toBe(opening)
  152. })
  153. })