orders.ts
Language TypeScript8.9 KB228 lines
/*** Orders, and (more importantly) the product-level statuses inside them.** Order status and product status disagree routinely: an order can read "On* Press" at the order level while its line items are still in "File Check". Counting orders by* `status_code` therefore undercounts the design queue.** So the unit of work here is the product line, not the order.*/import { assignedPerson } from '../assignment-values.js'import { GET_ORDERS_PARAMS, num, phpCollection, str, toPlinthDate } from '../hazards.js'import { projectOrder, projectProduct } from '../projection.js'import type { PlinthClient } from '../http.js'/** Days since the order was placed. Plinth records no status-change timestamps,* so this is age-since-order, not age-in-status. */function placedAgeDays(placed: string | null, now = new Date()): number | null {if (!placed) return nullconst t = Date.parse(placed.replace(' ', 'T'))return Number.isFinite(t) ? Math.floor((now.getTime() - t) / 86_400_000) : null}export interface OrderLine {orderId: number/** The product-line id. Exact per-line identity, unlike order_ref + item_ref. */ordersProductsId: number | nullproductId: number | nullorderStatus: stringorderStatusId: number/** The one that actually reflects where the work is. */productStatus: string/*** Preferred over the name for classification: status names* collide across the Order and Product catalogs ("File Check" is 53 and* 44, "On Press" is 40 and 10).*/productStatusId: number | nullproductTitle: stringquantity: numberprice: numbercustomer: stringcompany: string | null/** Despite the field name, this is when the order was placed. */placedAt: string | nullamount: numbervendor: string | nulluserGroup: string | null/*** The designer, from the API. The `--` sentinel is already* null here; the raw Plinth account label is mapped to a roster name in collect.ts.*/productionOwner: string | null/** The CS rep overseeing the order. Same normalization as productionOwner. */accountOwner: string | null/** Days since the order was placed. Best available age signal; Plinth records no* status-change timestamps, so this is age-since-order, not age-in-status. */ageDays: number | null}/*** How many fetched lines actually carried each assignment field: key present,* regardless of value.** production_owner / account_owner arrive on orders.fetch (order level and per* product line). Fields that arrived by vendor change can vanish by vendor* change, and a vanished field* would make every line read unassigned, which looks like free capacity, the* exact lie this board exists to prevent. So presence is counted, and the caller* reports zero coverage as missing rather than as zero work.** Presence is judged per line because Plinth omits keys per record rather than* sending them null: the same call returns different key sets across orders.*/export interface AssignmentFieldCoverage {production: numberaccountOwner: numbertotal: number}/*** A window of order lines, plus whether it is the whole window.** `truncated` is not optional and is not cosmetic. Plinth pages by product* line and gives no total, so a caller cannot tell a complete sweep from a* capped one by looking at the rows. When this is true the counts are a floor,* and a floor presented as a total misstates the window (rule 5). Same* contract as LeadWindow in the Bitrix client.*/export interface OrderWindow {lines: OrderLine[]/** True when maxPages ran out before Plinth returned a short page. */truncated: boolean/** Requests actually made. Useful for spotting a window that is too wide. */pages: number/** Assignment field presence across `lines`. Never inferred; counted. */assignmentFields: AssignmentFieldCoverage}export class OrdersResource {constructor(private readonly client: PlinthClient) {}/*** Orders placed in a date window, flattened to one row per product line.** Windowed rather than filtered by status because a sweep by status would need* one call per status. 30+ calls at 9-per-10-seconds is over half a minute for* data one windowed call can return.*/async linesInWindow(fromIso: string,toIso: string,limit = 500,maxPages = 20,): Promise<OrderWindow> {const raws: unknown[] = []let pages = 0let truncated = true// Paginate, and count the right thing when deciding to stop.//// `limit` and `offset` on orders.fetch are measured in product lines, not// orders. Against the live API://// limit=100 offset=0 -> 61 orders, 100 lines// limit=100 offset=100 -> 57 orders, 100 lines// limit=500 offset=0 -> 297 orders, 500 lines//// So stopping on `batch.length < limit` would compare orders against a line// budget: a full page of 100 lines is around 60 orders, so it would stop// after one page every time. Because Plinth orders by date ascending, that// one page is the oldest slice of the window, and the board would describe// a different fortnight while labelling it "the last 60 days".//// A short page is the only end signal Plinth gives, so the line count is what// has to be compared. maxPages is a guard, not an expectation; hitting it// means the window is wider than this can fetch, and `truncated` says so// rather than the caller assuming it got everything.for (let page = 0; page < maxPages; page++) {const body = await this.client.call<{ order_map?: Record<string, unknown> }>('orders.fetch',{since: toPlinthDate(fromIso),until: toPlinthDate(toIso),limit,offset: page * limit,},GET_ORDERS_PARAMS,)// null means "Invalid request", which is also how Plinth says zero results.const batch = body?.order_map ? Object.values(body.order_map) : []pages = page + 1raws.push(...batch)// Lines, not orders. See above.let batchLines = 0for (const o of batch) {batchLines += phpCollection<Record<string, unknown>>((o as Record<string, unknown>).lines,).length}if (batchLines < limit) {truncated = falsebreak}}const lines: OrderLine[] = []let poPresent = 0let aoPresent = 0for (const rawOrder of raws) {const o = projectOrder(rawOrder as Record<string, unknown>)const rawAll = rawOrder as Record<string, unknown>const products = phpCollection<Record<string, unknown>>(rawAll.lines)const customer = [str(o.first_name), str(o.last_name)].filter(Boolean).join(' ').trim()for (const rawProduct of products) {const p = projectProduct(rawProduct)const poInLine = 'production_owner' in rawProductconst aoInLine = 'account_owner' in rawProductif (poInLine || 'production_owner' in rawAll) poPresent++if (aoInLine || 'account_owner' in rawAll) aoPresent++lines.push({orderId: num(o.order_ref),ordersProductsId: p.line_ref != null ? num(p.line_ref) : null,productId: p.item_ref != null ? num(p.item_ref) : null,orderStatus: str(o.status_label) ?? 'unknown',orderStatusId: num(o.status_code),productStatus: str(p.line_status) ?? 'unknown',productStatusId: typeof p.line_status_code === 'number' ? p.line_status_code : null,productTitle: str(p.item_name) ?? '(untitled)',quantity: num(p.qty, 1),price: num(p.unit_price),customer: customer || '(no name)',company: str(o.company_name),placedAt: str(o.closed_on),amount: num(o.grand_total),vendor: str(o.press_name),userGroup: str(o.customer_group),// Present at order level and per product line. The per-line value// wins; the order-level value is used only when the product record// lacks the key, because a product-level `--` means this line is// unassigned even when the order names someone. `--` is the// unassigned sentinel (as observed live, whatever the vendor// documents) and becomes null here, never a person. Raw Plinth account// labels ("Marco-Acct7-Desk") pass through unmapped; collect.ts turns// them into roster names via config/plinth-people.ts.productionOwner: assignedPerson(poInLine ? str(rawProduct.production_owner) : str(rawAll.production_owner),),accountOwner: assignedPerson(aoInLine ? str(rawProduct.account_owner) : str(rawAll.account_owner),),ageDays: placedAgeDays(str(o.closed_on)),})}}return {lines,truncated,pages,assignmentFields: { production: poPresent, accountOwner: aoPresent, total: lines.length },}}}