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

orders.ts

Language TypeScript8.9 KB228 lines

packages/plinth-client/src/resources/orders.ts

  1. /**
  2. * Orders, and (more importantly) the product-level statuses inside them.
  3. *
  4. * Order status and product status disagree routinely: an order can read "On
  5. * Press" at the order level while its line items are still in "File Check". Counting orders by
  6. * `status_code` therefore undercounts the design queue.
  7. *
  8. * So the unit of work here is the product line, not the order.
  9. */
  10. import { assignedPerson } from '../assignment-values.js'
  11. import { GET_ORDERS_PARAMS, num, phpCollection, str, toPlinthDate } from '../hazards.js'
  12. import { projectOrder, projectProduct } from '../projection.js'
  13. import type { PlinthClient } from '../http.js'
  14. /** Days since the order was placed. Plinth records no status-change timestamps,
  15. * so this is age-since-order, not age-in-status. */
  16. function placedAgeDays(placed: string | null, now = new Date()): number | null {
  17. if (!placed) return null
  18. const t = Date.parse(placed.replace(' ', 'T'))
  19. return Number.isFinite(t) ? Math.floor((now.getTime() - t) / 86_400_000) : null
  20. }
  21. export interface OrderLine {
  22. orderId: number
  23. /** The product-line id. Exact per-line identity, unlike order_ref + item_ref. */
  24. ordersProductsId: number | null
  25. productId: number | null
  26. orderStatus: string
  27. orderStatusId: number
  28. /** The one that actually reflects where the work is. */
  29. productStatus: string
  30. /**
  31. * Preferred over the name for classification: status names
  32. * collide across the Order and Product catalogs ("File Check" is 53 and
  33. * 44, "On Press" is 40 and 10).
  34. */
  35. productStatusId: number | null
  36. productTitle: string
  37. quantity: number
  38. price: number
  39. customer: string
  40. company: string | null
  41. /** Despite the field name, this is when the order was placed. */
  42. placedAt: string | null
  43. amount: number
  44. vendor: string | null
  45. userGroup: string | null
  46. /**
  47. * The designer, from the API. The `--` sentinel is already
  48. * null here; the raw Plinth account label is mapped to a roster name in collect.ts.
  49. */
  50. productionOwner: string | null
  51. /** The CS rep overseeing the order. Same normalization as productionOwner. */
  52. accountOwner: string | null
  53. /** Days since the order was placed. Best available age signal; Plinth records no
  54. * status-change timestamps, so this is age-since-order, not age-in-status. */
  55. ageDays: number | null
  56. }
  57. /**
  58. * How many fetched lines actually carried each assignment field: key present,
  59. * regardless of value.
  60. *
  61. * production_owner / account_owner arrive on orders.fetch (order level and per
  62. * product line). Fields that arrived by vendor change can vanish by vendor
  63. * change, and a vanished field
  64. * would make every line read unassigned, which looks like free capacity, the
  65. * exact lie this board exists to prevent. So presence is counted, and the caller
  66. * reports zero coverage as missing rather than as zero work.
  67. *
  68. * Presence is judged per line because Plinth omits keys per record rather than
  69. * sending them null: the same call returns different key sets across orders.
  70. */
  71. export interface AssignmentFieldCoverage {
  72. production: number
  73. accountOwner: number
  74. total: number
  75. }
  76. /**
  77. * A window of order lines, plus whether it is the whole window.
  78. *
  79. * `truncated` is not optional and is not cosmetic. Plinth pages by product
  80. * line and gives no total, so a caller cannot tell a complete sweep from a
  81. * capped one by looking at the rows. When this is true the counts are a floor,
  82. * and a floor presented as a total misstates the window (rule 5). Same
  83. * contract as LeadWindow in the Bitrix client.
  84. */
  85. export interface OrderWindow {
  86. lines: OrderLine[]
  87. /** True when maxPages ran out before Plinth returned a short page. */
  88. truncated: boolean
  89. /** Requests actually made. Useful for spotting a window that is too wide. */
  90. pages: number
  91. /** Assignment field presence across `lines`. Never inferred; counted. */
  92. assignmentFields: AssignmentFieldCoverage
  93. }
  94. export class OrdersResource {
  95. constructor(private readonly client: PlinthClient) {}
  96. /**
  97. * Orders placed in a date window, flattened to one row per product line.
  98. *
  99. * Windowed rather than filtered by status because a sweep by status would need
  100. * one call per status. 30+ calls at 9-per-10-seconds is over half a minute for
  101. * data one windowed call can return.
  102. */
  103. async linesInWindow(
  104. fromIso: string,
  105. toIso: string,
  106. limit = 500,
  107. maxPages = 20,
  108. ): Promise<OrderWindow> {
  109. const raws: unknown[] = []
  110. let pages = 0
  111. let truncated = true
  112. // Paginate, and count the right thing when deciding to stop.
  113. //
  114. // `limit` and `offset` on orders.fetch are measured in product lines, not
  115. // orders. Against the live API:
  116. //
  117. // limit=100 offset=0 -> 61 orders, 100 lines
  118. // limit=100 offset=100 -> 57 orders, 100 lines
  119. // limit=500 offset=0 -> 297 orders, 500 lines
  120. //
  121. // So stopping on `batch.length < limit` would compare orders against a line
  122. // budget: a full page of 100 lines is around 60 orders, so it would stop
  123. // after one page every time. Because Plinth orders by date ascending, that
  124. // one page is the oldest slice of the window, and the board would describe
  125. // a different fortnight while labelling it "the last 60 days".
  126. //
  127. // A short page is the only end signal Plinth gives, so the line count is what
  128. // has to be compared. maxPages is a guard, not an expectation; hitting it
  129. // means the window is wider than this can fetch, and `truncated` says so
  130. // rather than the caller assuming it got everything.
  131. for (let page = 0; page < maxPages; page++) {
  132. const body = await this.client.call<{ order_map?: Record<string, unknown> }>(
  133. 'orders.fetch',
  134. {
  135. since: toPlinthDate(fromIso),
  136. until: toPlinthDate(toIso),
  137. limit,
  138. offset: page * limit,
  139. },
  140. GET_ORDERS_PARAMS,
  141. )
  142. // null means "Invalid request", which is also how Plinth says zero results.
  143. const batch = body?.order_map ? Object.values(body.order_map) : []
  144. pages = page + 1
  145. raws.push(...batch)
  146. // Lines, not orders. See above.
  147. let batchLines = 0
  148. for (const o of batch) {
  149. batchLines += phpCollection<Record<string, unknown>>(
  150. (o as Record<string, unknown>).lines,
  151. ).length
  152. }
  153. if (batchLines < limit) {
  154. truncated = false
  155. break
  156. }
  157. }
  158. const lines: OrderLine[] = []
  159. let poPresent = 0
  160. let aoPresent = 0
  161. for (const rawOrder of raws) {
  162. const o = projectOrder(rawOrder as Record<string, unknown>)
  163. const rawAll = rawOrder as Record<string, unknown>
  164. const products = phpCollection<Record<string, unknown>>(rawAll.lines)
  165. const customer = [str(o.first_name), str(o.last_name)]
  166. .filter(Boolean)
  167. .join(' ')
  168. .trim()
  169. for (const rawProduct of products) {
  170. const p = projectProduct(rawProduct)
  171. const poInLine = 'production_owner' in rawProduct
  172. const aoInLine = 'account_owner' in rawProduct
  173. if (poInLine || 'production_owner' in rawAll) poPresent++
  174. if (aoInLine || 'account_owner' in rawAll) aoPresent++
  175. lines.push({
  176. orderId: num(o.order_ref),
  177. ordersProductsId: p.line_ref != null ? num(p.line_ref) : null,
  178. productId: p.item_ref != null ? num(p.item_ref) : null,
  179. orderStatus: str(o.status_label) ?? 'unknown',
  180. orderStatusId: num(o.status_code),
  181. productStatus: str(p.line_status) ?? 'unknown',
  182. productStatusId: typeof p.line_status_code === 'number' ? p.line_status_code : null,
  183. productTitle: str(p.item_name) ?? '(untitled)',
  184. quantity: num(p.qty, 1),
  185. price: num(p.unit_price),
  186. customer: customer || '(no name)',
  187. company: str(o.company_name),
  188. placedAt: str(o.closed_on),
  189. amount: num(o.grand_total),
  190. vendor: str(o.press_name),
  191. userGroup: str(o.customer_group),
  192. // Present at order level and per product line. The per-line value
  193. // wins; the order-level value is used only when the product record
  194. // lacks the key, because a product-level `--` means this line is
  195. // unassigned even when the order names someone. `--` is the
  196. // unassigned sentinel (as observed live, whatever the vendor
  197. // documents) and becomes null here, never a person. Raw Plinth account
  198. // labels ("Marco-Acct7-Desk") pass through unmapped; collect.ts turns
  199. // them into roster names via config/plinth-people.ts.
  200. productionOwner: assignedPerson(
  201. poInLine ? str(rawProduct.production_owner) : str(rawAll.production_owner),
  202. ),
  203. accountOwner: assignedPerson(
  204. aoInLine ? str(rawProduct.account_owner) : str(rawAll.account_owner),
  205. ),
  206. ageDays: placedAgeDays(str(o.closed_on)),
  207. })
  208. }
  209. }
  210. return {
  211. lines,
  212. truncated,
  213. pages,
  214. assignmentFields: { production: poPresent, accountOwner: aoPresent, total: lines.length },
  215. }
  216. }
  217. }