mattrguerra/meridian-ops-boardmainPrivate on GitHub, published here in full

status-taxonomy.ts

Language TypeScript9.9 KB226 lines

packages/plinth-client/src/status-taxonomy.ts

  1. /**
  2. * Plinth product-status taxonomy: the judgment layer.
  3. *
  4. * Plinth has no notion of "open work." It has statuses. Whether a status means Meridian
  5. * owes the next move, the client owes it, a vendor owes it, or the line is done
  6. * is a judgment that differs per status, and no system can tell you.
  7. *
  8. * 'ours' Meridian owes the next move. The only class that counts as load.
  9. * 'client' Blocked on the client. A follow-up list, not workload. Aging here
  10. * measures client responsiveness, not our capacity.
  11. * 'vendor' At the printer or in transit. Delivery risk, not load.
  12. * 'parked' Deliberately paused.
  13. * 'terminal' Done, cancelled, or refunded.
  14. *
  15. * These are judgements read from status names. The 'ours' vs 'client' calls
  16. * are the ones to confirm with the people who run production: that boundary
  17. * decides whether a number describes the team's backlog or the client's.
  18. */
  19. export type WorkClass = 'ours' | 'client' | 'vendor' | 'parked' | 'terminal'
  20. export interface StatusRule {
  21. id: number
  22. name: string
  23. class: WorkClass
  24. /** Design-queue statuses roll up into the designer-load number. */
  25. design?: boolean
  26. note?: string
  27. }
  28. /** catalog: "Product", the granular, truthful layer. */
  29. export const PRODUCT_STATUSES: StatusRule[] = [
  30. { id: 176, name: 'File Check', class: 'ours', design: true, note: 'The designer queue.' },
  31. { id: 144, name: 'Design Requested', class: 'ours', design: true },
  32. { id: 101, name: 'Proof In Progress', class: 'ours', design: true },
  33. { id: 120, name: 'Revisions In Progress', class: 'ours', design: true },
  34. { id: 115, name: 'Reproof Needed', class: 'ours', design: true },
  35. { id: 136, name: 'Files Resubmitted', class: 'ours', design: true, note: 'Back in the queue; needs re-review.' },
  36. { id: 138, name: 'Waiting On Files', class: 'client' },
  37. { id: 161, name: 'Proof With Client', class: 'client' },
  38. { id: 122, name: 'Question For Client', class: 'client' },
  39. { id: 175, name: 'Proof Signed Off', class: 'ours', note: 'Approved, not yet moved on. Should be near zero.' },
  40. { id: 145, name: 'Cleared For Print', class: 'ours' },
  41. { id: 179, name: 'On Press', class: 'vendor' },
  42. { id: 182, name: 'At Partner Shop', class: 'vendor' },
  43. { id: 112, name: 'Partner Dispatched', class: 'vendor' },
  44. { id: 126, name: 'Packed', class: 'ours' },
  45. { id: 103, name: 'Payment Due', class: 'client' },
  46. { id: 143, name: 'Line Cancel Requested', class: 'ours', note: 'Needs a decision.' },
  47. { id: 139, name: 'Line Cancelled', class: 'terminal' },
  48. { id: 194, name: 'Line Refunded', class: 'terminal' },
  49. { id: 157, name: 'Signed Off With Edits (Retired)', class: 'terminal', note: 'Marked for deletion by Plinth.' },
  50. ]
  51. /**
  52. * catalog: "Order", the coarser layer.
  53. *
  54. * Kept separate from the product catalog on purpose. The two use different ids
  55. * for the same label ("File Check" is 121 here and 176 there) and they do not
  56. * contain the same set: `Proof Out` (159) exists only at order level. Merging
  57. * them would let an order-level status fall through the product classifier
  58. * and be counted as "our move" when it is actually sitting with the client.
  59. */
  60. export const ORDER_STATUSES: StatusRule[] = [
  61. { id: 134, name: 'Received', class: 'ours' },
  62. { id: 171, name: 'Allocated', class: 'ours' },
  63. { id: 121, name: 'File Check', class: 'ours', design: true },
  64. { id: 181, name: 'Design Requested', class: 'ours', design: true },
  65. { id: 195, name: 'Proof In Progress', class: 'ours', design: true },
  66. { id: 186, name: 'Revisions In Progress', class: 'ours', design: true },
  67. { id: 159, name: 'Proof Out', class: 'client', note: 'Waiting on client approval. NOT team load.' },
  68. { id: 167, name: 'Waiting On Files', class: 'client' },
  69. { id: 163, name: 'Payment Due', class: 'client' },
  70. { id: 168, name: 'Proof Signed Off', class: 'ours', note: 'Approved, not yet moved on.' },
  71. { id: 185, name: 'Cleared For Print', class: 'ours' },
  72. { id: 177, name: 'Rush Print', class: 'ours' },
  73. { id: 140, name: 'Rush Prep', class: 'ours' },
  74. { id: 111, name: 'PO In Review', class: 'ours' },
  75. { id: 137, name: 'Payment Error', class: 'ours', note: 'Revenue at risk.' },
  76. { id: 106, name: 'Cancel Requested', class: 'ours', note: 'Needs a decision.' },
  77. { id: 108, name: 'Packed', class: 'ours' },
  78. { id: 125, name: 'On Press', class: 'vendor' },
  79. { id: 117, name: 'PO Dispatched', class: 'vendor' },
  80. { id: 133, name: 'Dispatched', class: 'vendor' },
  81. { id: 162, name: 'Part Dispatched', class: 'vendor' },
  82. { id: 193, name: 'Awaiting Collection', class: 'vendor' },
  83. { id: 135, name: 'On Hold', class: 'parked' },
  84. { id: 189, name: 'Delivered', class: 'terminal', note: 'Graveyard: thousands of records, none ever exit.' },
  85. { id: 129, name: 'Cancelled', class: 'terminal' },
  86. { id: 166, name: 'Refunded', class: 'terminal' },
  87. { id: 191, name: 'Claim Opened', class: 'ours' },
  88. { id: 131, name: 'Claim Settled', class: 'terminal' },
  89. { id: 158, name: 'PO Paid', class: 'ours' },
  90. { id: 196, name: 'PO Payment Due', class: 'client' },
  91. { id: 107, name: 'Legacy Review (retired)', class: 'terminal' },
  92. { id: 174, name: 'Legacy Processing (retired)', class: 'terminal' },
  93. ]
  94. const orderById = new Map(ORDER_STATUSES.map((s) => [s.id, s]))
  95. const orderByName = new Map(ORDER_STATUSES.map((s) => [s.name.toLowerCase(), s]))
  96. function resolveOrder(statusName: string, statusId?: number | null) {
  97. if (typeof statusId === 'number') {
  98. const hit = orderById.get(statusId)
  99. if (hit) return hit
  100. }
  101. return orderByName.get(statusName.trim().toLowerCase())
  102. }
  103. export function classifyOrder(statusName: string, statusId?: number | null): WorkClass {
  104. return resolveOrder(statusName, statusId)?.class ?? 'ours'
  105. }
  106. export function isOrderDesignQueue(statusName: string, statusId?: number | null): boolean {
  107. return resolveOrder(statusName, statusId)?.design ?? false
  108. }
  109. export function isKnownOrderStatus(statusName: string, statusId?: number | null): boolean {
  110. return resolveOrder(statusName, statusId) !== undefined
  111. }
  112. const byId = new Map(PRODUCT_STATUSES.map((s) => [s.id, s]))
  113. const byName = new Map(PRODUCT_STATUSES.map((s) => [s.name.toLowerCase(), s]))
  114. /**
  115. * Resolve a status rule, preferring the ID.
  116. *
  117. * `line_status_code` matters more than it looks: status names are duplicated
  118. * across the two catalogs. "File Check"
  119. * is id 121 as an Order status and id 176 as a Product status; "On Press" is
  120. * 125 and 179; "Proof Signed Off" is 168 and 175. Matching on the name alone cannot
  121. * tell those apart, so a name-keyed lookup is silently one wrong field away
  122. * from classifying an order-level status against product-level rules.
  123. *
  124. * The name fallback stays for records that carry no id.
  125. */
  126. function resolve(statusName: string, statusId?: number | null) {
  127. if (typeof statusId === 'number') {
  128. const hit = byId.get(statusId)
  129. if (hit) return hit
  130. }
  131. return byName.get(statusName.trim().toLowerCase())
  132. }
  133. /** Unknown statuses classify as 'ours' so a new one is noticed, not hidden. */
  134. export function classifyProduct(statusName: string, statusId?: number | null): WorkClass {
  135. return resolve(statusName, statusId)?.class ?? 'ours'
  136. }
  137. export function isDesignQueue(statusName: string, statusId?: number | null): boolean {
  138. return resolve(statusName, statusId)?.design ?? false
  139. }
  140. /**
  141. * Order statuses past the point where the line has left the building.
  142. *
  143. * A line is ours when the product status says ours and the order has not moved
  144. * past Part Dispatched. So Part Dispatched is still
  145. * eligible and these two are the first that are not; Delivered, Cancelled
  146. * and Refunded are caught by their `terminal` class instead of being listed
  147. * again here.
  148. */
  149. const PAST_FULFILMENT = new Set([
  150. 133, // Dispatched
  151. 193, // Awaiting Collection
  152. ])
  153. /**
  154. * Has this order moved beyond the point where anybody here still owes it a move?
  155. *
  156. * Unknown statuses answer false: the line keeps showing. Same direction as
  157. * `classifyProduct`'s unknown default: a status nobody has classified yet should
  158. * be noticed on the page, not quietly filtered off it.
  159. */
  160. export function isOrderPastFulfilment(statusName: string, statusId?: number | null): boolean {
  161. const rule = resolveOrder(statusName, statusId)
  162. if (!rule) return false
  163. return rule.class === 'terminal' || PAST_FULFILMENT.has(rule.id)
  164. }
  165. /**
  166. * The one classifier for an order line. Product status decides, unless the
  167. * order has shipped, in which case the line is done whatever the product row
  168. * says. Every count on the board goes through this, so the counts agree with
  169. * the product status the Status column shows.
  170. *
  171. * Neither field alone is right:
  172. *
  173. * - Order status alone hides real design work. An order sitting On Press
  174. * with a product line still in File Check is somebody's job today.
  175. * - Product status alone invents work that is already out of the door. The
  176. * product row parks at its last value when an order ships, so many
  177. * design-stage product lines sit on orders already shipped or delivered.
  178. * Counting those makes the team look behind when it is not.
  179. *
  180. * So: the product row says what the work is, and the order says whether the
  181. * work is still here, with the cutoff at Part Dispatched.
  182. */
  183. export function classifyLine(line: {
  184. productStatus: string
  185. productStatusId?: number | null
  186. orderStatus: string
  187. orderStatusId?: number | null
  188. }): WorkClass {
  189. if (isOrderPastFulfilment(line.orderStatus, line.orderStatusId)) return 'terminal'
  190. return classifyProduct(line.productStatus, line.productStatusId)
  191. }
  192. /** Does this line's design-queue claim still stand? Same gate. */
  193. export function isLineDesignQueue(line: {
  194. productStatus: string
  195. productStatusId?: number | null
  196. orderStatus: string
  197. orderStatusId?: number | null
  198. }): boolean {
  199. if (isOrderPastFulfilment(line.orderStatus, line.orderStatusId)) return false
  200. return isDesignQueue(line.productStatus, line.productStatusId)
  201. }
  202. export const CLASS_LABELS: Record<WorkClass, string> = {
  203. ours: 'Our move',
  204. client: 'Waiting on client',
  205. vendor: 'At vendor / in transit',
  206. parked: 'Parked',
  207. terminal: 'Closed',
  208. }