Integration API Reference
v3.7 REST Read + Write
API Reference

Auto-IT One

The unified REST API for Auto-IT South Africa — structured access to customer accounts, invoices, credit notes, payments, statements and more. Read endpoints across the board, with a focused set of write operations for CRM data.

Protocol HTTPS Format JSON Auth Header Keys Access Read + Write Version 2.8

Introduction

Auto-IT One gives integrated systems structured access to financial account data maintained within Auto-IT. Read endpoints use GET and return JSON. As of v2.1 a small set of PATCH/POST write endpoints can update customer contact details and notes.

Each endpoint is a standalone PHP file served from the API root — e.g. /customers.php, /invoices.php. There is no routing layer.
Invoice line items are included. Invoice responses embed parts, labour, misc, vehicle, accessory and trade-in detail per module (I Parts · V Vehicle · W Workshop).
Write scope is intentionally narrow. Writes cover CRM/contact data only. Financial postings (invoices, payments, allocations) and inventory quantities are not writable here — they must go through Auto-IT so the ledger, aging and stock stay consistent.

Authentication

Every request must include two custom HTTP headers. Both are validated before any query executes. A missing or incorrect header returns 401 or 403 immediately.

AUTHKEYyour-auth-keyIdentifies the calling application
APIKEYyour-api-key-secretCredential paired to the AUTHKEY
WRITEKEYyour-write-keyRequired only for write endpoints — second factor for mutations
Try it: Enter your keys once in any endpoint's modal — they are saved for the rest of your session.
Example — cURL
bash
curl -X GET "https://your-server/api/customers.php?acc_no=1001" \
     -H "AUTHKEY: your-auth-key" \
     -H "APIKEY: your-api-key-secret"

Conventions

TopicDetail
Base URLhttps://your-server.com/api/ — replace with your hosting path
MethodAll endpoints are GET — read-only
EncodingUTF-8. All responses carry Content-Type: application/json
DatesPass as YYYY-MM-DD in query parameters
Pagination?page= (default 1) and ?limit=. Leave limit blank to return all rows; pass a number for that many (an unusable value falls back to 20). Responses include a meta block
AmountsNumeric, 2 decimal places. Balances calculated live from the allocations ledger
Tran typesIN Invoice · CR Credit Note · RC Receipt · DS Discount
Debug modeAppend ?debug=1 to return generated SQL without executing it
Standard list response
json
{ "meta": { "total": 1284, "page": 1, "limit": 20, "total_pages": 65 }, "data": [ /* records */ ] }

Error Codes

CodeMeaningCommon cause
200OKRequest succeeded
400Bad RequestMissing required parameter
401UnauthorizedAUTHKEY or APIKEY header absent
403ForbiddenKey pair does not match
404Not FoundRecord does not exist
405Method Not AllowedNon-GET request sent
500Server ErrorDatabase error — check the message field
Executive

Executive Summary

A single top-management snapshot assembled from the platform's tables: debtors and creditors balances with aging, working capital, vehicle / wholegoods stock by type with retail value, and parts and workshop activity. Bound the activity window with date_from / date_to.

GET executive_summary.php ?date_from= &date_to=
Covers what's computable today. The full Financial Position (P&L, balance sheet, ratios) comes from the general ledger and needs the GL tables; gross profit / margin everywhere needs the cost columns (vehicle / parts / labour). Those are added as soon as those schemas are shared.
KeyTypeRequiredDescription
date_from / date_todateoptActivity window for parts / workshop
json
{ "debtors": { "total": 12840300.00,
    "aging": { "current": 8100200.00, "days_30": 2510000.00 } },
  "creditors": { "total": 9650100.00 },
  "working_capital": 3190200.00,
  "vehicle_stock": { "by_type": [{ "type": "N", "units": 128, "retail_value": 78200000.00 }] },
  "parts_sales": { "sale_value": 4210880.00 },
  "workshop": { "repair_orders": 3421, "labour_value": 2980400.00 } }

Customer Anatomy

A 360° view of one debtor — profile and contact, the live AR position (balance, aging, credit limit and terms), what they spend with the dealership by department, and their most recent transactions and invoices.

GET customer_anatomy.php ?acc_no={n} &date_from= &date_to=

Spend is drawn from the Invoice ledger by department (module). Omit the date window for lifetime spend.

KeyTypeRequiredDescription
acc_nointegerreqDebtor account number
date_from / date_todateoptSpend window (omit for lifetime)
json
{ "profile": { "ACC_NO": 1001, "company_name": "Acme Parts" },
  "ar_position": { "balance": 48250.00, "credit_limit": 100000.00,
    "aging": { "current": 30000.00, "days_30": 18250.00 } },
  "spend": { "totals": { "invoices": 214, "total": 1875400.00 },
    "by_module": [{ "module": "W", "total": 920100.00 }] } }
Admin · Accounts Receivable

Debtors

The debtor (accounts-receivable) master — account and contact detail joined across the master debtor, customer settings, and contact tables. Includes billing and postal address, VAT/ABN, credit terms, trading details, and live aging. Served by customers.php.

GET customers.php ?search= &acc_no= &status= …

Paginated customer list. Use ?search= for partial match across company name, surname, first name, and email. Pass ?acc_no= to retrieve a single account with full detail and live aging.

KeyTypeRequiredDescription
searchstringoptPartial match on company name, surname, name, email
acc_nointegeroptExact account — returns single record with aging breakdown
statusstringoptCredit status code — e.g. O = Open
trade_typestringoptFilter by trade type code
territorystringoptFilter by territory code
pageintegeroptDefault: 1
limitintegeroptblank = all rows; number = that many (else 20)
json
{ "meta": { "total": 842, "page": 1, "limit": 20 },
  "data": [{ "ACC_NO": 1001, "company_name": "Acme Parts",
    "abn": "4320000001", "CREDIT_LIMIT": 50000.00,
    "Credit_Status": "O", "city": "Johannesburg" }] }

Debtor Transactions

Reads ArTrans (the live AR ledger). Each row carries its allocation position — allocated_to, allocated_by, and a net outstanding (positive = still owed, negative = unapplied credit). Pass ?include_archive=1 to also return historical rows from ArTrans_Old.

GET debtor_transactions.php ?acc_no= &tran_type= &include_archive= …

Filter by acc_no for a single debtor. Response includes page totals; archive rows (if requested) come back in their own shape.

KeyTypeRequiredDescription
acc_nointegeroptDebtor account — recommended
tran_typestringoptIN · CR · RC · DS
refstringoptExact document reference
receipt_nostringoptExact receipt number
date_from / date_todateoptTRANS_DATE range
searchstringoptref, description, receipt_no
include_archiveintegeropt1 appends ArTrans_Old (capped 200)
page / limitintegeroptlimit blank = all rows; number = that many (else 20)
json
{ "meta": { "total": 214 },
  "page_totals": { "value": 48250.00, "outstanding": 12300.00 },
  "data": [{ "REF": "INV00123", "TRAN_TYPE": "IN",
    "VALUE": 2255.00, "allocated_to": 2000.00,
    "outstanding": 255.00, "TRANS_DATE": "2025-04-15 09:22:11" }] }

Debtor Statements

Generates a complete account statement for a given period. The response is self-contained — everything needed to render or print a statement in one call.

GET statement.php ?date_from={date}&date_to={date} &acc_no=

Returns opening balance, transactions with running balance, closing balance, summary by type, and aging. Pass ?acc_no= for a single statement, or omit it to return paginated statements for all accounts under statements[].

Multi-account mode defaults to 10 per page (max 50) — each statement runs several queries so keep limit low.
KeyTypeRequiredDescription
date_fromdatereqPeriod start — YYYY-MM-DD
date_todatereqPeriod end — YYYY-MM-DD
acc_nointegeroptSingle account — omit to return all accounts
statusstringoptFilter by credit status in multi-account mode — e.g. O
currencystringoptRestrict to a specific foreign currency
page / limitintegeroptMulti-account only · Defaults: 1 / 10, max limit: 50
json
{ "customer": { "acc_no": 1001, "company_name": "Acme Parts", "abn": "4320000001" },
  "opening_balance": 8500.00,
  "transactions": [{ "TRAN_TYPE": "IN", "VALUE": 2255.00, "running_balance": 10755.00 }],
  "closing_balance": 5755.00,
  "aging": { "current_age": 2255.00, "days_30": 1800.00, "total_outstanding": 5755.25 } }
Admin · Accounts Payable

Creditors

Reads from APMASTER (the creditor master) joined to contact via CONTACT_CODE. A single creditor returns the full record plus a live open balance, aging, and recent transactions — all derived from ApTrans.

GET creditors.php ?acc_no= &search= &status= …

Paginated creditor list with open balance. ?acc_no= returns a single creditor with aging and recent transactions.

KeyTypeRequiredDescription
acc_nointegeroptSingle creditor + balance, aging, recent txns
searchstringoptcompany, surname, name, email, VAT/ABN
statusstringoptCREDIT_STATUS code
trade_termstringoptTRADE_TERM code
branchstringoptBranch code
foreignintegeropt1 = foreign entities only
page / limitintegeroptlimit blank = all rows; number = that many (else 20)
json
{ "meta": { "total": 318 },
  "data": [{ "ACC_NO": 5001, "company_name": "Bosch SA",
    "TRADE_TERM": "30", "CREDIT_STATUS": "O",
    "YTD_PURCH_VAL": 842500.00, "open_balance": 128400.00 }] }

Creditor Transactions

Reads ApTrans (the live AP ledger). Each row carries outstanding = GROSS_AMOUNT − PART_PAID_GROSS. Pass ?include_archive=1 to also return historical rows from OldTrans under an archive block.

GET creditor_transactions.php ?acc_no= &include_archive= …

Filter by acc_no for a single creditor. Response includes page totals; archive rows (if requested) come back in their own shape.

KeyTypeRequiredDescription
acc_nointegeroptCreditor account — recommended
doc_typestringoptApTrans.DOC_TYPE
statusstringoptApTrans.STATUS
doc_nostringoptExact document number
order_numberstringoptExact PO number
date_from / date_todateopttrans_date range
searchstringoptdoc_no, description, payment_ref
include_archiveintegeropt1 appends OldTrans (capped 200)
rec_typestringoptFilter OldTrans.REC_TYPE (archive only)
page / limitintegeroptlimit blank = all rows; number = that many (else 20)
json
{ "meta": { "total": 96 },
  "page_totals": { "gross": 128400.00, "outstanding": 128400.00 },
  "data": [{ "DOC_NO": "PINV4471", "DOC_TYPE": "IN",
    "GROSS_AMOUNT": 14200.00, "outstanding": 14200.00,
    "DUE_DATE": "2025-06-30 00:00:00" }] }

Creditor Statement

A full AP statement for one creditor over a period: opening balance, period transactions with a running balance, closing balance, summary and aging.

GET creditor_statement.php ?acc_no={n}&date_from={date}&date_to={date}

All three parameters are required. Each transaction contributes its unpaid gross to the running balance.

KeyTypeRequiredDescription
acc_nointegerreqCreditor account number
date_fromdatereqPeriod start — YYYY-MM-DD
date_todatereqPeriod end — YYYY-MM-DD
json
{ "creditor": { "acc_no": 5001, "company_name": "Bosch SA" },
  "opening_balance": 114200.00,
  "transactions": [{ "DOC_NO": "PINV4471", "outstanding": 14200.00, "running_balance": 128400.00 }],
  "closing_balance": 128400.00,
  "aging": { "current": 80000.00, "days_31_60": 48400.00, "total_outstanding": 128400.00 } }
Admin · System

Branches

Reads branch_name and tags each branch with the tier-1 departments it actually operates — wholegoods (VhStock), parts (InTrans) and workshop (WKINVREG) — so a client can show only the branches relevant to the active tab. Presence is history-wide, so the flags don't flicker as a date filter changes. Drives the global Branch filter.

GET branches.php

No parameters. Each department flag is a correlated EXISTS probe into the branch column, so it short-circuits on the first match rather than scanning for distinct values.

KeyTypeRequiredDescription
——optNo query parameters. ?debug=1 returns the SQL.
json
{ "count": 7,
  "data": [{ "branch": "01", "name": "Head Office",
    "has_wholegoods": 1, "has_parts": 1, "has_workshop": 1 }] }

Branch Info

Reads branch_name — the table behind Branches — for the branch's record rather than its picker entry: address, phones, email, manager, trader registration, branch type and main branch, and the workshop sizing figures the DMS keeps on the branch row (bays, advisors, loan cars, booked hours per day and days per week). Branches is what fills a dropdown; this is what a consumer reads when it has to show a customer where the branch is and how to reach it, or seed its own branch setup from the DMS. A separate file because the two shapes share nothing past the key, and a picker that carried twenty columns would pay for them on every page load. The column list is fixed, and deliberately short of the table: the workshop charge rates and their min/max, stamp duty, Transfer_In/Transfer_Out, dtf_path, note, the invoice messages, survey counters, bin settings and receipting flags are financial or internal configuration, not branch identity, and none of them should be one careless SELECT * away from a customer screen. A consumer that needs one asks for it and it is added by name. Per-user branch scoping is enforced upstream, as for Branches.

GET branch_info.php ?branch= …

Every branch, ordered by name, or one branch with ?branch= — the same envelope either way, so a caller reads "one" and "all" with the same code. Every key is on every row in a fixed order, lower-case and aliased in the SQL so it does not depend on how an install spells the column. Values are cleaned here, not by the caller: strings pass through toUtf8() and are trimmed — most of these columns are CHAR and come back space-padded — and a blank becomes null, so "no phone recorded" is one value, not '' and '   '. The four counts are integers and bf_hours_per_day / bf_days_per_week (NUMERIC(6,2)) are floats. A NULL column is null, never 0: a branch with no bay count recorded has not got zero bays, and a capacity screen that read 0 would say it cannot take work.

KeyTypeRequiredDescription
branchstringoptOne branch code, e.g. M0102. An exact match on the trimmed code (TRIM(b.branch) = '…', escaped inside a quoted literal) — not a list and not a partial match; the TRIM() is not sargable and does not need to be on a table of one row per branch. A code that matches nothing answers 200 with count: 0, not 404. Blank is the same as absent: every branch
debugintegeropt1 = the usual profiler: the query runs, and _debug carries its SQL, ms and row count
json
{ "count": 1,
  "data": [{ "branch": "M0102", "name": "BARBERTON - M0102",
    "street": "12 Main Rd", "city": "Barberton", "state": "Mpumalanga", "pcode": "1300",
    "phone_bus": "013 712 3456", "phone_mob": null, "phone_home": null,
    "email_address": "service@example.co.za", "branch_manager": "J SMITH", "trader_regn_no": null,
    "branch_type": "M", "main_branch_code": "M0101", "company": "M01",
    "wshop_no_of_bays": 6, "wshop_advisor_count": 3, "no_of_loan_cars": 2,
    "time_adjust_minute": 0,
    "bf_hours_per_day": 8.5, "bf_days_per_week": 5.5 }] }
POST branch_info.php { branch, …contact fields }

Writes a branch's contact details and manager, and nothing else — the address, the business and mobile phone, the email and the branch manager's name — against the branch code. It is how ServiceConnect's Branch setup saves them back to the DMS. Name, branch type, company and the workshop sizing figures are not writable: they are the DMS's own to keep. Only keys present in the body are written; a key sent as "" or null writes NULL, so clearing a phone is a deliberate act and leaving it out is not. Values are trimmed and converted to Windows-1252, the encoding every read here converts back from, and a value longer than its column is refused with a 400 naming the field — never truncated, because a cut-off street is worse than an error. Every value is a string and every one is bound. WRITEKEY header required. 404 when the branch does not exist. The answer carries the row as the GET now reads it, so a caller shows what the DMS holds rather than what it sent.

FieldTypeRequiredDescription
branchstringreqThe DMS branch code, e.g. M0102 — matched on the trimmed code, and never itself written
street, city, statestringoptUp to 30 characters each. state is the province
pcodestringoptPostal code, up to 10 characters
phone_bus, phone_mobstringoptBusiness and mobile phone, up to 15 characters each
email_addressstringoptUp to 50 characters, validated as an email
branch_managerstringoptThe branch manager's name, up to 30 characters
Add ?dry_run=1 to return the exact SQL + bound params without writing.
json
{ "ok": true, "branch": "M0102",
  "updated": ["street", "phone_bus"],
  "count": 1,
  "data": [{ "branch": "M0102", "name": "BARBERTON - M0102", "street": "14 Main Rd", … }] }

Accounting Periods

Reads syscalendar — the financial calendar. Each row is one accounting period: year_end (financial year), period_start (first day) and period_me (last day = period_start + no_of_day − 1). By default only periods on or before the current month are returned — the periods you can post into.

GET accounting_periods.php ?all= &year= …

Periods up to the current month by default. ?all=1 includes future periods; ?year= bounds to one financial year.

KeyTypeRequiredDescription
allintegeropt1 = include future periods
yearintegeroptFinancial year, e.g. 2026
json
{ "filters": { "all": 0, "year": null }, "count": 9,
  "data": [{ "year_end": 2026, "period_start": "2025-03-01",
    "period_me": "2025-03-31" }] }

Franchises

Reads codtyp where TYPE = 'FC' — the franchise code list — translating a franchise code (the FRANCHISE column on InMaster / InTrans) to its display name, long description and chip colours. This is the lookup behind the franchise_name now returned by the parts endpoints.

GET franchises.php ?code= …

All franchise codes, ordered by sequence. ?code= returns a single franchise.

KeyTypeRequiredDescription
codestringoptExact franchise code
json
{ "count": 6,
  "data": [{ "franchise": "TY", "name": "Toyota",
    "long_description": "Toyota South Africa", "color_bg": "#EB0A1E",
    "sequence_no": 1, "read_only": "N" }] }

Invoices & Credit Notes

Reads from the Invoice table. Invoices and credit notes are linked to a debtor via bill_to_acc = acc_no. Type codes: I = Invoice · C = Credit Note. Module codes: I = Parts · V = Vehicle · W = Workshop.

GET invoices.php ?document_no= &type= &acc_no= &module_type= …

Paginated list of invoices and credit notes from the Invoice table. Pass ?document_no= to fetch a single record with full detail, outstanding balance, and allocation history.

KeyTypeRequiredDescription
document_nostringoptSingle invoice / credit note — returns full detail + allocation history
typestringoptinvoice → invo_type = I  ·  credit → invo_type = C
acc_nointegeroptDebtor account number (bill_to_acc)
module_typestringoptI = Parts  ·  V = Vehicle  ·  W = Workshop
salesmanstringoptPartial match on salesman code
currencystringopt3-letter ISO currency code
date_fromdateoptYYYY-MM-DD
date_todateoptYYYY-MM-DD
cancelledintegeropt0 exclude cancelled  ·  1 only cancelled
searchstringoptPartial match on company name, surname, doc no, order no
page / limitintegeroptlimit blank = all rows; number = that many (else 20)
json
{ "meta": { "total": 3201 },
  "data": [{ "document_no": "INV00123", "invo_type": "I", "module_type": "W",
    "bill_to_acc": 1001, "company_name": "Acme Parts",
    "parts_sale_val": 1200.00, "labour_sale_val": 850.00,
    "gst": 205.00, "invoice_total": 2255.00,
    "invo_datetime": "2025-04-15 09:22:11", "cancel_date": null }] }

Allocated Payments

Reads from ArTrans_Alloc — the table that records payments once they have been matched against invoices. Each row represents an allocation: a payment applied to a specific invoice, with the amount matched and the dates involved.

GET payments.php ?acc_no= &date_from= &date_to= &invoice_ref=

Returns all allocated payment records from ArTrans_Alloc, joined to ArTrans for payment detail and Invoice for invoice detail. Filter by acc_no to get all payments for a specific debtor.

KeyTypeRequiredDescription
acc_nointegeroptDebtor account number — recommended for performance
invoice_refstringoptExact invoice document number — returns all payments against that invoice
date_fromdateoptAllocation date from — YYYY-MM-DD
date_todateoptAllocation date to — YYYY-MM-DD
page / limitintegeroptlimit blank = all rows; number = that many (else 20)
json
{ "meta": { "total": 142 },
  "data": [{
    "invoice_rec_no":       45201,
    "payment_rec_no":       46100,
    "amount_allocated":     2255.00,
    "allocation_date":      "2025-05-02 00:00:00",
    "invoice_ref":          "INV00123",
    "payment_ref":          "EFT-20250502",
    "payment_date":         "2025-05-02 00:00:00",
    "payment_amount":       -5000.00,
    "acc_no":               1001,
    "document_no":          "INV00123",
    "invo_type":            "I",
    "module_type":          "W",
    "company_name":         "Acme Parts"
  }] }

Top X Customers

Returns the top 20 debtors ranked by total invoice sales value (excl. and incl. tax). Only invoices (invo_type = I) are included — credit notes are excluded. Optionally filter by date range or module type.

GET top_customers.php ?date_from= &date_to= &module_type=

Aggregates invoice sales by debtor account, joins customer details, and returns the top 20 sorted by sales_incl_tax descending. Includes invoice count, sales excluding tax, total tax, and sales including tax per customer.

KeyTypeRequiredDescription
topintegeroptNumber of customers to return, max 100 · Default: 20
date_fromdateoptInvoice date range start — YYYY-MM-DD
date_todateoptInvoice date range end — YYYY-MM-DD
module_typestringoptI = Parts  ·  V = Vehicle  ·  W = Workshop
json
{ "filters": { "date_from": "2025-01-01", "date_to": "2025-12-31", "module_type": null },
  "count": 20,
  "data": [{
    "acc_no":          1001,
    "company_name":    "Acme Parts Pty Ltd",
    "email_address":   "accounts@acme.co.za",
    "vat_no":          "4630198010",
    "credit_limit":    50000.00,
    "invoice_count":   84,
    "sales_excl_tax":  284500.00,
    "total_tax":       42675.00,
    "sales_incl_tax":  327175.00
  }] }

Vehicle Models

Reads VhModel — the model catalogue, which is what the manufacturer sells, not what the dealer has. Vehicle Stock reads VhStock (physical units on the floor) and Vehicle Sales the ones that left. This is the list behind a "which car is it" picker, and it exists because a job card raised for a car the DMS has never met has to describe that car from something.

Three dropdowns and a payload. ?makes=1 → ?models=1&make= → ?variants=1&make=&model_code=, then ?model_code=&variant= for the row — which comes back with a create_ro_vehicle object already in the field names Create a Job Card accepts and already cut to the widths WKVEHFL declares. Paste it straight in as that create's vehicle{} key. Nothing downstream has to know VhModel spells it MODEL_CODE and WKVEHFL spells it MODEL.

GET vehicle_models.php ?makes=1 &models=1 &variants=1 &cascade=1 …
A picker must not show raw rows. VhModel is keyed (MODEL_CODE, VARIANT) but carries LINENO as an autoincrement and valid_from/valid_to as a window — so the same car appears more than once. A price change, a spec revision or a new model year files another row, and a dropdown built on SELECT * shows "D-Max 250 HO" four times with no way to tell which is which. Every picker mode here is DISTINCT over the naming columns; the detail lookup takes the newest row for the pair (valid_from DESC, LINENO DESC); and the list keeps ?latest_only=1 on by default. ?latest_only=0 shows every version, which is what you want when the question is "what did this cost in March".

Validity is filtered, and the filter measures itself. ?valid_on= (default: today on the database clock) keeps rows whose window contains that date, treating a null bound as open — a discontinued model in a booking picker is how a job card acquires a spec nobody sells any more. But a null window is the common case on some installs, and a validity filter over a catalogue nobody dates is a filter that does nothing while looking like it works. So every response carries meta.validity.dated_pct — the share of rows carrying either bound — and a warning when it is 0 or under half. Read it before trusting that the picker shows current models. ?all_versions=1 turns it off.

Nothing is inferred from TYPE or Category. Both are single-character-ish code columns whose domain is documented nowhere this codebase can see, so this file does not decide that N means new. They are returned raw, they filter exactly, and ?facets=1 reports what values actually exist — the same rule the workshop endpoints apply to their own two-letter codes. Prices are off by default: SALES_PRICE, COST_PRICE, DEALER_MARGIN, HOLDBACK_AMOUNT and Min_Sell_Margin sit on this table, and a picker feeding a service booking has no business carrying a dealer's margin to a browser. ?prices=1 opts in — not a security boundary, since the read keys already reach it, just a default that keeps the common payload free of numbers nobody asked for.

One mapping in create_ro_vehicle is a guess, and it says so. VhModel has Service_Cycle NUMERIC(4) and Service_Cycle_Unit NUMERIC(8); WKVEHFL has SERVICE_CYCLE and SERV_CYC_KM. The widths make the pairing likely — four digits reads as a month count and eight as a distance — but likely is not confirmed, and the two the wrong way round schedules a car for its next service 15 000 months out. meta.mapping_note states which way round it guessed. Check one model against an Auto-IT screen; drop the two keys at the caller if it is reversed. Everything else in the block is a straight rename.
KeyTypeRequiredDescription
makesflagopt1 = dropdown 1. Distinct Make, and nothing else.
modelsflagopt1 = dropdown 2. Distinct (MODEL_CODE, MODEL_NAME), normally with ?make=. Each row carries a label that falls back to the code, because a blank option is unpickable.
variantsflagopt1 = dropdown 3. Distinct VARIANT under a make + model, each with its engine, fuel, transmission and body so the option can read "250HO4X4 — 2500 · DIESEL · MAN" without a fourth request.
cascadeflagopt1 = the whole make → model → variant tree, nested, in one query. For a picker that would rather hold it in memory than make three round trips. Capped at 20 000 distinct rows; over that it returns what it has and sets meta.truncated.
model_codestringoptExact, comma list as a filter. Together with a single variant it becomes the detail lookup and returns create_ro_vehicle. One half of the key on its own identifies nothing, so it stays a filter.
variantstringoptExact, comma list. See model_code.
makestringoptExact, comma list. Matched upper-case on both sides — a picker returns ISUZU and callers send Isuzu often enough that a case-sensitive match would read as "that make has no models".
franchisestringoptExact, comma list on Franchise.
fuel_typestringoptExact, comma list. Also body_type, transmission, category, type.
searchstringoptPartial match on MODEL_CODE, MODEL_NAME, VARIANT and Description.
valid_ondateoptYYYY-MM-DD. Default is today on the database clock — a web server an hour ahead of the DMS would otherwise drop a model that became valid this morning.
all_versionsflagopt1 = ignore the validity window entirely.
latest_onlyflagoptList mode. 1 (default) = newest row per (MODEL_CODE, VARIANT). 0 = every version.
pricesflagopt1 = include the six price columns on list and detail rows. Off by default.
facetsflagopt1 = picker lists for the secondary filters. Each ignores its own filter, so a picker never narrows itself to the one value already chosen.
schemaflagopt1 = the live column list, the validity coverage and multi_version_models. Run this once per install — those two numbers decide whether the defaults above suit your data.
sortstringoptmake (default) | model | variant | name | price.
pageintegeroptDefault 1.
limitintegeroptBlank = all rows.
debugflagopt1 = per-query timing / size in a _debug block.
json — ?model_code=DMAX250&variant=250HO4X4
{ "source": "VhModel",
  "model": { "model_code": "DMAX250", "variant": "250HO4X4",
              "model_name": "D-Max 250 HO Double Cab", "make": "ISUZU",
              "body_type": "DOUBLE CAB", "fuel_type": "DIESEL",
              "engine_size": "2500", "transmission": "MAN",
              "cylinders": 4, "service_cycle": 12, "service_cycle_unit": 15000,
              // TYPE and Category come back raw — nothing here decides what they mean
              "type": "N", "category": "LCV", "line_no": 8842 },
  "versions_on_file": 4,
  // paste this straight in as the vehicle{} key of workshop.php?create_ro=1
  "create_ro_vehicle": { "make": "ISUZU", "model": "DMAX250",
      "model_name": "D-Max 250 HO Double Cab", "variant": "250HO4X4",
      "engine_size": "2500", "fuel_type": "DIESEL",
      "transmission": "MAN", "body_type": "DOUBLE CAB",
      "franchise": "IS", "cylinders": 4,
      "service_cycle": 12, "service_cycle_km": 15000 },
  "meta": { "validity": { "applied": true, "dated_pct": 0.0,
                 "warning": "No row on VhModel carries a valid_from or a valid_to…" },
            "mapping_note": "service_cycle ← Service_Cycle, service_cycle_km ← Service_Cycle_Unit. NOT confirmed…" } }

Vehicle Stock

Reads from VhStock — the new, used and demo units. Pass ?stock_no= for a single unit including the accessories allocated to it (VhStockAccess, keyed on stock number) and its most recent appraisal.

GET vehicles.php ?stock_no= &make= &model= &status= …

Paginated list of units in stock (no sales invoice, not swapped out). ?stock_no= returns a single unit with accessories and appraisal. Sold units are in vehicle_sales.php.

KeyTypeRequiredDescription
stock_nointegeroptSingle unit + accessories + appraisal
makestringoptExact make
modelstringoptPartial model match
statusstringoptStock status code
fuel_typestringoptExact fuel type
year_from / year_tointegeroptModel-year range
reg / vinstringoptRegistration / VIN match
searchstringoptmake, model, vin, reg, stock_no
page / limitintegeroptlimit blank = all rows; number = that many (else 20)
json
{ "meta": { "total": 312 },
  "data": [{ "stock_no": 12345, "make": "Toyota", "model": "Hilux",
    "vin_no": "AHT…", "stock_status": "A", "sale_price_incl": 689900.00 }] }

Vehicle Accessories

Reads VhAccess — the master catalogue of every accessory available for sale, with cost/sell pricing, GST and tax flags, factory/supplier references and effectivity dates. Accessories actually allocated to a unit are returned per stock number by vehicles.php?stock_no=.

GET vehicle_accessories.php ?search= &make= &type= &active= …

Paginated accessory catalogue. Note that Make = 'ALL' is the catch-all make.

KeyTypeRequiredDescription
searchstringoptcode, description, alternate description
codestringoptExact accessory code
makestringoptMake (ALL = catch-all)
typestringoptVhAccess.TYPE
categorystringoptcodtyp_category
branchstringoptBranch code
displayintegeropt1 = DisplayIndicator 'Y' only
activeintegeropt1 = effective today only
page / limitintegeroptlimit blank = all rows; number = that many (else 20)
json
{ "meta": { "total": 1240 },
  "data": [{ "code": "TOWBAR-HX", "description": "Tow Bar Kit",
    "make": "Toyota", "sales_price": 4500.00,
    "sales_price_inc_gst": 5175.00, "display_indicator": "Y" }] }

Vehicle Sales

Reads VhStock and returns only units that have been sold — a sales invoice is stamped on them. The complement of Vehicle Stock; the two sets don't overlap. For a sold unit's full detail (accessories, appraisal) use vehicles.php?stock_no=.

GET vehicle_sales.php ?date_from= &date_to= &sales_invoice= &make= …

Paginated list of sold units, newest sale first. Each page carries page_totals (units and sale value). Gross profit awaits the VhStock cost column.

KeyTypeRequiredDescription
date_from / date_tostringoptSale-date range (SALESDATE), YYYY-MM-DD
sales_invoicestringoptExact sales invoice number
makestringoptExact make
modelstringoptPartial model match
reg / vinstringoptRegistration / VIN match
year_from / year_tointegeroptModel-year range
searchstringoptmake, model, vin, reg, stock_no
page / limitintegeroptlimit blank = all rows; number = that many (else 20)
json
{ "meta": { "total": 1820 },
  "page_totals": { "units": 20, "sales_value": 7430000.00 },
  "data": [{ "stock_no": 500124, "make": "Toro", "model": "WM1110",
    "sales_invoice": "35923", "sales_date": "2026-05-18 14:54:17",
    "sales_value": 35000.00 }] }

Workshop Customers

A lean lookup over the people the workshop actually deals with — a CONTACT row that some vehicle names as its driver. One row per person, minimal fields, vehicles nested. Built for two jobs: find the customer fast from a partial name, plate or email — and, in detail mode, resolve a signed-in cellphone number to that person's contact record and cars. Served by workshop_customers.php.

GET workshop_customers.php ?q= &mobile= &contact_code= &limit= &offset=

?q= is partial, multi-token and AND-ed — the whole point of the endpoint. ?q=smi ca123 is part of a surname and part of a plate; ?q=smi @gmail is part of a surname and part of an email; ?q=john smith works in either order. Split on whitespace, each token must match something, and a token only has to match one field — which is what lets the two halves land on completely different tables and still describe one person. Searched: surname, name, company_name, both email columns, and through the vehicle probe reg, make, variant.

Prefix, not contains — and that is what makes it quick. Every leg is LIKE 'term%'. A leading wildcard cannot be seeked by any B-tree, because the thing an index orders by is the start of the value — so a contains-search is a scan of the person master on every keystroke no matter what is indexed. Prefix is also what people type: the start of a surname, of a plate, of an email. The cost is real: mith will not find Smith. If that matters more than the speed does, it is a one-line change — but it should be a decision, not a default.

Only customers with a vehicle, and it is an EXISTS, not a join. The requirement is contact.contact_code = wkvehfl.driver. Written as a join that is person×vehicle grain — a customer with three cars is three rows, the same name three times, and ?limit=20 returns seven people. Written as an EXISTS it is a per-person test that seeks the driver index and stops at the first hit, so the row count is the people count and paging means what it says. The vehicles then arrive in a second keyed query over just the page's contact codes, merged server-side — the same pattern salesReinvoiceCostMap() settled on after a join version of the same idea went from ~290 ms to 4 500 ms.

Phones are returned but not searched, deliberately. The DMS stores numbers both ways — 0114520580 and 268 2404 3501 are both in this data — so matching them properly means comparing digits to digits, which means REPLACE(REPLACE(…col…)): a function over the column that no index survives. One such leg would turn every keystroke into a full scan of the person master and undo everything else here. If you want phone search, the honest options are a normalised search column maintained alongside the DMS, or accepting the scan behind its own explicit parameter. ?mobile= below is that explicit parameter — the scan, paid once per number rather than per keystroke.

Detail mode: ?mobile= and ?contact_code=. Added for the Service Connect customer app, where a person signs in with a cellphone number verified by SMS. The app resolves the number to contact codes once with ?mobile=, keeps the codes, and reads them back afterwards with ?contact_code= — a keyed seek on contact(contact_code). Either parameter switches the response to detail: each contact also carries title, the street and postal address columns, the privacy flags, consent_date / consent_expiry_date and last_modified_date; each vehicle also carries model (the DMS model code), model_name and its specification in create_ro's own vehicle{} names — model_version, vin, chassis, engine, engine_size, fuel_type, transmission, body_type, colour, franchise, first_reg — so a car known at one of a group's dealerships can be created properly at another that has never seen it. Both can be combined with ?q=; the search mode without them is unchanged.

?mobile= is a scan of CONTACT. It matches mob_phone only — never prv_phone or bus_phone, where a switchboard number would link everyone at a company — with spaces, dashes, brackets, dots and + stripped and the last nine digits compared, so 082 123 4567, 0821234567 and +27 82 123 4567 are one number. That is a function over the column, which no index survives. Fewer than nine digits is a 400. Every matching contact is returned: duplicate contacts for one person are common. Call it once per number and keep the codes; do not put it behind a keystroke.
Detail mode is an allowlist. The contact table also holds bank details, ID / social security number, date of birth, driver's licence, tax fields, Note and Sys_Reserved. None of them is selected, in any mode — this feeds an app a member of the public reads.

meta.total respects ?q= — the count runs the same predicate, so a search matching thirty people reports thirty, not the whole book. Paging is ?limit= / ?offset= with meta.next_offset and meta.has_more carrying the arithmetic. The ORDER BY surname, name, contact_code is both what a person expects to read and what makes offset coherent: without a deterministic sort, "skip the first fifty" means skipping an arbitrary fifty.

Indexes. Registered in Ensure Indexes: contact(surname, name), contact(name, surname), contact(company_name), contact(email_address) and wkvehfl(driver, reg, make, variant). Each makes one leg of the search capable of seeking, and the last one is index-only for all three things it serves — the population test, the vehicle search probe, and the second query's entire projection. Detail mode needs nothing new: ?contact_code= seeks contact(contact_code), its vehicles seek the driver index and read model / model_name off a handful of rows, and ?mobile= cannot use an index at all. Whether SQL Anywhere actually index-ORs a five-leg predicate or gives up and scans is a question only ?debug=1 on live data answers. Measure before believing any of this is fast.

KeyTypeRequiredDescription
qstringoptSearch. Whitespace-split, AND-ed, prefix-matched. A token only has to match one field. Tokens under 2 characters are dropped — with a prefix match one character selects a large slice of the table and narrows nothing.
mobilestringoptCellphone in any format, at least nine digits. The last nine are matched against contact.mob_phone with separators stripped. A scan — call once per number and keep the codes. Switches on detail mode.
contact_codestringoptExact contact code(s), comma list. A keyed seek. Switches on detail mode.
limitintegeroptRows. Default 50, maximum 500.
offsetintegeroptRows to skip. Default 0. Echo meta.next_offset back to page.
debugflagopt1 = per-query SQL, timing and payload size in a _debug block.
json
{ // GET ?q=smi ca123 — a Smith with a plate starting CA123
  "count": 1,
  "data": [
    { "contact_code": "C0004182",
      "name": "John", "surname": "Smith",
      "company_name": null,
      // returned, but not searchable — see the note above
      "mob_phone": "082 123 4567", "prv_phone": null,
      "bus_phone": null,
      "email_address": "jsmith@example.co.za",
      "email_address_2": null,
      // second keyed query, merged server-side — never a join
      "vehicles": [
        { "reg": "CA123456", "make": "TOYOTA",
          "variant": "2.8 GD-6", "vehicle": "TOYOTA 2.8 GD-6" },
        { "reg": "ND4471", "make": "FORD",
          // variant missing: || would have made the whole string NULL
          "variant": null, "vehicle": "FORD" }] }],
  "meta": {
    // runs the SAME predicate — 1 of 1, not 1 of 41 208
    "total": 1, "returned": 1,
    "limit": 50, "offset": 0,
    "next_offset": null, "has_more": false,
    "q": "smi ca123",
    "tokens": ["smi", "ca123"],
    "match": "prefix", "mode": "search",
    "mobile_last9": null, "contact_codes": null } }

{ // GET ?mobile=+27821234567 — detail mode (same shape for ?contact_code=C0004182)
  "count": 1,
  "data": [
    { "contact_code": "C0004182",
      "name": "John", "surname": "Smith", "company_name": null,
      "mob_phone": "082 123 4567", "prv_phone": null, "bus_phone": null,
      "email_address": "jsmith@example.co.za", "email_address_2": null,
      // detail only
      "title": "MR",
      "street": "12 Main Road", "street_2": null,
      "city": "Nelspruit", "state": "Mpumalanga",
      "pcode": "1200", "country": "South Africa",
      "postal_street_1": "PO Box 44", "postal_street_2": null,
      "postal_city": "Nelspruit", "postal_state": null,
      "postal_pcode": "1200", "postal_country": null,
      "privacy_service": "Y", "privacy_marketing": "N",
      "privacy_3rd_party": "N", "privacy_other": null,
      "consent_date": "2025-03-02 09:14:00", "consent_expiry_date": null,
      "last_modified_date": "2026-08-30 11:02:41",
      "vehicles": [
        { "reg": "CA123456", "make": "TOYOTA",
          "variant": "2.8 GD-6", "vehicle": "TOYOTA 2.8 GD-6",
          // detail only — model is the DMS model CODE
          "model": "HILUXDC", "model_name": "HILUX DOUBLE CAB" }] }],
  "meta": { "total": 1, "returned": 1, "limit": 50, "offset": 0,
    "next_offset": null, "has_more": false,
    "q": null, "tokens": [], "match": "prefix",
    "mode": "detail", "mobile_last9": "821234567", "contact_codes": null } }

Repair Orders

Reads from WKROFILE with the serviced vehicle from WKVEHFL. A single RO returns the same line-item breakdown as a workshop invoice — parts (InTrans + InMaster), labour (hours × value), consumables / misc, and a combined ro_totals summary.

It also owns the RO progress status list. ?progress_statuses=1 returns the CODTYP WC code table — the board columns, with the DMS's own descriptions, colours and display order — and POST ?update_progress_status=1 maintains it: rename a stage, recolour its badge, reorder the picker, add one. Moving a job between stages is Service Bookings; this is the list those moves are made from. The write needs the WRITEKEY header.

GET workshop.php ?ro_number= &reg= &search= …

Paginated repair-order list. ?ro_number= returns a single RO with vehicle, parts, labour and consumable/misc lines plus combined totals.

?progress_statuses=1 is the board columns on their own. Every CODTYP WC code — the domain of WKROFILE.RO_PROGRESS_STATUS — with the DMS's own description, display colours, class, sequence and read-only flag, ordered by Sequence_No, code as tiebreak, unsequenced stages last. That is everything an app needs to draw a job board; Service Bookings ?stages=1 adds how many open ROs are sitting on each, and a POST there moves a job between them. The row comes back via SELECT c.* for the same reason the RO header does — the CODTYP layout is not curated here, naming the colour columns would 500 on an install that spells one differently, and raw carries the untouched record. The colours are passed through unchanged: this API does not know their encoding, and a Windows colour integer is BGR, not the RGB hex a browser wants — look at what your install returns before feeding them to CSS.

The repair-order header is returned as-is (SELECT rf.*) so every real WKROFILE column comes through. Filtering is limited to ro_number / reg until the WKROFILE schema is confirmed. Parts are read from InTrans by REF_NO = RO number.
KeyTypeRequiredDescription
progress_statusesflagopt1 = the RO progress statuses and nothing else — CODTYP WC, the domain of WKROFILE.RO_PROGRESS_STATUS: code, long_des as description, tc_description, colour_bg / colour_text, code_class, sequence_no, read_only, and the untouched DMS record under raw. The board columns, in Sequence_No order with the code as tiebreak and unsequenced stages last — ordered in SQL and again in PHP, because SQL Anywhere sorts NULLs first and a stage nobody has sequenced would otherwise jump to the top of the picker. meta.ordered_by says which order you got: if it reports the code fallback, the column is there and empty, and one POST with a sequence[] batch sets it for good. page / limit are ignored — a picker arriving a page at a time would offer a board with columns missing. stages is an accepted alias. Short-circuits before any RO query is built. See also Service Bookings?stages=1 for the same codes with live open-RO counts, and its POST half to move a job between them.
ro_numberintegeroptSingle RO + vehicle + parts + labour + misc
regstringoptExact registration
searchstringoptreg, RO number
page / limitintegeroptlimit blank = all rows; number = that many (else 20)
json
{ "data": { "RO_NUMBER": 10234, "reg": "CA123456",
    "vehicle": { "make": "Ford", "model": "Ranger" },
    "parts_items": { "parts_items_total": { "qty": 6, "sale_value": 2240.00 } },
    "labour_items": { "labour_items_total": { "hrs": 2.5, "labour_sale": 1875.00 } },
    "misc_items": { "misc_items_total": { "value": 120.00 } },
    "ro_totals": { "parts_sale": 2240.00, "labour_sale": 1875.00, "total_sale": 4235.00 } } }
POST workshop.php ?update_progress_status=1 &dry_run=1 …

Maintains the stage list itself — rename a stage, recolour its badge, reorder the picker, add one. The settings panel behind the board, where Service Bookings' POST moves a job between stages and this one changes what the stages are. It writes codtyp WHERE type = 'WC' and nothing else — every statement carries that TYPE and it is not a parameter, because CODTYP is the whole DMS's code table and TC (technician teams) and FC (franchises) live in it too. ?update_progress_status=1 is required on the POST: it is the mode gate, so this file's repair-order half can never be reached by a write.

Absent means leave it, null means clear it. Every field but code is independently omittable — which is the point of not making this a send-me-the-whole-row call, since an app that has to resend colour_bg in order to change a name will eventually resend a stale one. create: true is required to insert: an unknown code without it is a 404, because the common mistake is a typo in an existing code and an endpoint that helpfully creates CWW beside CW turns that typo into a permanent row nobody can trace. create: true on a code that exists is a 409. if_current_description is the optimistic lock — same mechanism as if_current_status on the stage move, and refused with 409 plus the current row when somebody renamed the stage on a DMS terminal in the meantime.

It does not delete, and there is no active flag to offer instead. A CODTYP row is pointed at by every RO that has ever been in that stage, so removing it makes that history resolve to nothing — a LEFT JOIN quietly returning a blank stage on ROs going back years, recoverable from neither the app nor this API. The soft-delete shape would be the right answer and this table has no column for it: Read_Only exists and is not the same thing — it says the DMS does not expect the code to be set by hand, not that it is withdrawn. A body carrying active is a 400 saying exactly that rather than a silent no-op. Renaming a code is also out: it rewrites the meaning of every historic RO carrying it. A code that is wrong gets a successor, not an edit.

A reorder is one request or none. Dragging one row in a list of a dozen renumbers most of them, and a half-applied reorder is a picker in an order nobody chose — which looks like a working list rather than a failed write. So sequence as an array is the batch form and it runs in one transaction, the way the attendance write handles a batch of days, reporting transactional: false rather than pretending if the driver will not release autocommit. Every code in it must exist: an unknown one is a 404 and nothing is written, because a reorder that quietly skipped one would leave the list in an order the caller did not ask for and report success. A sequence array and single-stage fields in one body is a 400 — two requests in one envelope.

The colour format is measured, not assumed. This endpoint has never seen a live CODTYP row and will not enforce a format it was only told about — an install storing Windows BGR integers would have every legitimate write rejected by a hex rule, and one storing hex would accept an integer that renders as a colour nobody chose. So the shape is taken from the rows already there: the distinct colours on type WC are read once and classified as all-hex, all-numeric or neither, and a value that does not match what the table already holds is a 400 naming both. ?allow_any_colour=1 skips it for the first row on an install where the column is empty. That inference is also the answer to the open question in the read half's note.
KeyTypeRequiredDescription
update_progress_statusflagreq1 — the mode gate. A POST without it is a 400: nothing else on this file is writable. A different word from the read's progress_statuses on purpose — a flag that says update cannot be arrived at by a client that meant to read — though the read's own flags are accepted here too, so an app pairing the two calls needs only one spelling.
dry_runflagopt1 = the statements, their bound params and the before → after per field. Writes nothing.
allow_any_colourflagopt1 = skip the colour-shape check, for the first row on an install whose colour columns are empty.
debugflagopt1 = per-query timing / size in a _debug block.

Body (JSON). code identifies the stage and is required. Then any of description (→ long_des, ≤60) · short_description (→ tc_description) · colour_bg · colour_text · sequence (→ Sequence_No) · code_class · read_only. The DMS spellings long_des, tc_description, color_bg, color_text and sequence_no are all accepted as aliases, and sending both spellings with different values is a 400 rather than a coin toss. Add create: true to insert, if_current_description to lock. A column this install does not have is a 400 naming the live column list, not a driver error.

json — rename and recolour one stage
{ "code": "CW",
  "description": "Customer Waiting",
  "colour_bg": "F4A261",
  "if_current_description": "Cust. Waiting" }
// short_description, colour_text and sequence are absent, so they are untouched.
// send "colour_text": null to CLEAR one.
json — reorder the picker, one transaction
{ "sequence": [
    { "code": "BI",  "sequence": 10 },
    { "code": "CW",  "sequence": 30 },
    { "code": "CO",  "sequence": 90 } ] }
// all of it or none of it. Nothing else may ride along in this body.
json
{ "changed": true, "created": false, "rows_affected": 1,
  // field for field what ?progress_statuses=1 returns for this row — it IS
  // that function, so the two cannot drift into two shapes for one thing
  "status": { "code": "CW", "description": "Customer Waiting",
    "short_description": "Cust Wait", "colour_bg": "F4A261",
    "colour_text": null, "code_class": null,
    "sequence_no": 30, "read_only": false,
    "type": "WC", "raw": { /* the untouched DMS record */ } },
  "meta": { "measured": false } }

// already exactly that — nothing written, and not an error
{ "changed": false, "status": { /* … */ } }
POST workshop.php ?create_ro=1 &probe=1 &dry_run=1 …

Creates a job card — three tables in one transaction, on this same file rather than an endpoint of its own: workshop.php is the repair-order feed, and the last five writes in this API all went on the file that owned the read. One URL family for repair orders beats a second filename to learn. It writes WkRoFile (the header: car, customer, slot, advisor), WKOTHSUB (one row per job on the card, with its estimate) and WKRODESC (the description lines under each job, with their values). All three, or none.

The RO number is claimed from syconfig, and the claim is the hard part. The next number lives in one row — sys_key = 'Next RO Number' — and it is global, not per branch. Two branches share one sequence, so a collision is not a local accident: it is one dealership overwriting another's job card. Read-then-update is a race and this does not do it. The claim is an UPDATE first, inside the transaction that does the inserts, so the row lock is taken before the value is read and held until the commit: autocommit off → UPDATE syconfig SET value_integer = value_integer + 1 → SELECT value_integer → insert header, jobs, lines → COMMIT. A second caller blocks at its own update and then reads a number that has already moved. A failed create consumes no number — the rollback takes the counter with it, which is the other reason the claim belongs inside the transaction rather than in front of it.

If the driver will not release autocommit, this refuses. Every other write in this API degrades gracefully and reports meta.transactional: false; this one does not, and the difference is what is at stake. A non-atomic claim on a counter shared across the whole database is precisely how two branches write the same RO. A create that cannot be atomic should not happen at all.

?probe=1 checks the counter against reality before anybody tries: the syconfig value beside MAX(RO_NUMBER) actually on file, with a verdict. healthy means the counter is ahead. TIGHT means it equals the highest RO, so the next claim lands on an existing one. BEHIND means something has been creating ROs without advancing the counter and every claim from here would collide — the create refuses in that state too, not only the probe. It also reports the LINE_NO verdict. Reads only.

Almost nothing is mandatory. The DDL (read 2026-09-04) has only BRANCH and RO_NUMBER NOT NULL on the header; on WKOTHSUB the four key columns plus Multiplier and Service_Contract_Ind, and both of those default. So this writes the key, whatever the caller gave it, and nothing else — a default the DMS chose (ro_closed_ind 'N', Quotation_Ind 'N', vettedStatus 'Y', Confirmed_Status 'N') is a better answer than one this endpoint invented. Lop_Id defaults to a connection value the API does not set and should not fake; Lop_Datetime is DEFAULT TIMESTAMP, which maintains itself. Neither is ever written. WkRoFile.Creation_Date is set, because unlike WKOTHSUB's it has no default — an asymmetry that belongs to the DDL, not to a choice here.
DATETIME_IN is not what this API calls datetime_in, and is never written here. WkRoFile has a real DATETIME_IN column — almost certainly when the vehicle arrived. Service Bookings emits a computed field of the same name built from expected_datetime. Two different facts, one word, across an API boundary. A booking is a promise about the future and arrival is an event, so this sets req_datetime and expected_datetime — and sets both, following whichever one you send, for the same reason the booking-date write does: half this API dates a booking by one column and half by the other.

It creates the car and the person when they are missing. A booking form is where a workshop first meets a customer, so refusing an unknown registration would stop the flow dead on most new business. An unknown reg becomes a WkVehFl row from the optional vehicle{} block; a customer who cannot be matched becomes a CONTACT row from the optional customer{} block — matched first on mobile, then email, then exact name, with two matches a 409 carrying the candidates rather than a choice between two humans. Both happen inside the same transaction as the card, so a create that fails leaves no orphan car and no orphan person. Passing driver explicitly skips the matching entirely. The cost, stated once: a typo'd registration now creates a vehicle rather than being refused — one junk row per typo, visible, corrupting nothing downstream. It still creates no invoice, no clocking and no part: a job card is the plan, and everything that happens to it afterwards belongs to the DMS.

reg is normalised — upper case, no whitespace. KZP 418 MP is filed as KZP418MP. This is not tidying. REG is the exact join key from WkRoFile to WKVEHFL — v.REG = rf.reg, here and on every Service Bookings row — and nothing trims or upper-cases it at read time, so a card filed under one spelling against a vehicle stored under another joins to nothing and comes back with no make, no model and no service history. That is the same failure the old unknown-reg refusal existed to prevent, arriving through the back door. The spacing a person typed into a booking form is not data. The lookup ignores spaces on both sides, so a car filed years ago as KZP 418 MP is found when a booking calls it KZP418MP rather than being duplicated — and when it is found the card is filed under the stored spelling, because the join is exact. Only a car being created for the first time gets the canonical form. The response carries reg_filed whenever it differs from what you sent, and ?dry_run=1 reports the whole decision under vehicle before anything is written. Hyphens and slashes survive — they are in real plates.
Send a customer{} block whenever the car is new. Service Bookings joins WKVEHFL with AND vf.DRIVER IS NOT NULL, so a vehicle created without a driver does not merely lose the customer name — it fails that join outright, and the booking comes back with no make and no model either, exactly as if the car had never been created. The row is there and the job card is correct; nothing downstream says why. This is not refused, because the card is still worth having — but it comes back as a warning under also_created.vehicle, and it is the single most likely way to end up with a booking that looks empty.

A car can gain a driver later. If the registration is already on WKVEHFL with a null DRIVER and this card resolves a customer, the blank is filled — the one exception to "it never updates an existing vehicle", because filling a blank cannot lose information and the blank is exactly what makes the row invisible. So a car that arrived before its customer did (one create with no customer{}, then a later one with) is recoverable rather than stranded. A DRIVER already set is never touched. Reported under also_created.vehicle_driver_filled.

There is no third source for the customer. A customer{} block, or a driver code — that is all. In particular nothing is read out of online_booking_notes: a booking system that puts the name and mobile in a free-text note has not sent them, and parsing a human sentence into a person master is how a CONTACT file fills up with "Customer not linked" as a surname. Map the note's fields into customer{} at the caller.

Phone numbers are normalised on the way in — digits and a leading +, nothing else. 082 551 0934 is stored as 0825510934. The matching always reduced both sides to digits before comparing, so a person entered twice matched fine and then sat on CONTACT under two spellings of one number — and every consumer that compares phone numbers without that reduction, which is every consumer outside this endpoint, read them as two people. Storing the reduced form makes the matched value and the stored value the same string. It also buys column room: mob_phone is CHAR(15) and +27 82 551 0934 is sixteen characters, three of them spaces. Applies to mobile, bus_phone and prv_phone, on write only.

The response says which of four things happened, in customer.basis: supplied (you passed a driver) · matched (the details found somebody already on CONTACT — the good outcome, since creating a customer twice splits their service history) · created · none. That field exists because also_created answers "what was written", which collapses three of those four into an empty block — a card that matched an existing person and a card that named nobody at all looked identical, and they need opposite responses. none is not an error; it is a card with no customer on it, and it carries the reason inline.

WKRODESC.LINE_NO is the one allocator left — NOT NULL, no default, and nothing in this API had ever written one. It is per (branch, ro, job_code, type) and this creates a new RO, so there is never anything to append to: lines are numbered from the first, in body order. Whether the first is 0 or 1 is inferred from the minimum LINE_NO of existing job groups and refused when the evidence is thin or contradictory — the same treatment, for the same reason, that Technician Attendance gives its own LINE_NO. ?line_no_basis=zero|one settles it.

KeyTypeRequiredDescription
create_roflagreq1 — the mode gate. A POST without it is the progress-status write; neither is inferred from the body, because a typo would then choose the other write and these two touch different tables.
probeflagopt1 = counter health and the LINE_NO verdict. Reads only. Run this before anything else, and read ro_number.counter_vs_file.
dry_runflagopt1 = every statement with its bound params. Claims no number — the counter is never touched by a dry run.
line_no_basisstringoptzero | one. Override the inferred WKRODESC base. Omit to let it infer and refuse on thin evidence.
allow_unknown_customerflagopt1 = file the card under a driver code that is not on CONTACT, rather than refusing. Only ever reached when driver is passed explicitly — a customer{} block never needs it, because an unmatched customer is created. There is no equivalent for the vehicle: an unknown reg is created, not refused.
allow_duplicate_source_idflagopt1 = raise a second card under an external_source_id that already has one. Without it a repeat booking id creates nothing and returns the existing card with created: false — which is what makes a retry after a timeout safe. Almost never what you want.
allow_unknown_job_typeflagopt1 = accept a TYPE letter this API does not label. Without it the job would come back with a null job_type on every feed.
allow_counter_behindflagopt1 = create even though syconfig is behind the file. Read the probe's warning first — the claim would land on a live job card.
debugflagopt1 = per-query timing / size in a _debug block.

Body (JSON). branch and jobs[] are required — send "jobs": [] for a header with no jobs yet, since an omitted key is treated as a mistake rather than as an empty card. Header: reg · driver · req_datetime · expected_datetime · odometer · salesman · franchise · team_code · tag_no · customer_note · customer_order_no · account_code · charge_account · ro_progress_status · quotation · confirmed · est_hours · est_starttime · datetime_out · marketing_source · pay_method · loan_car_reg · booked_in_by · external_source · external_source_id. Optional vehicle{} (used only when the reg is new): make · model · model_name · variant · vin · engine · colour · fuel_type · transmission · first_reg · franchise · next_service_date and the rest of WkVehFl. Optional customer{} (used only when nobody matches): name · surname · mobile · email · street · city · pcode · privacy_service · privacy_marketing. Each job: job_code and job_type required, plus description · notes · work_cat · est_hours · est_labour · est_parts · est_sublet · est_total · field_repair · claim_no · sort_order. Each line: detail · value · qty · unit_of_measure. Every column not listed is left to its DMS default.

Set external_source and external_source_id — they are now load-bearing. Besides provenance, the pair is this endpoint's idempotency key. A create is the one call here where a timeout is worse than a failure: the caller cannot tell "the card was not made" from "the card was made and the answer was lost", and those need opposite responses. When both are set, WkRoFile is asked whether that booking already raised a card — and if it did, the call creates nothing, consumes no RO number, and answers 200 with created: false, duplicate: true and the existing ro_number. So a retry after a lost response is safe. Branch on created, not on the status code. Both halves must match: external_source_id alone collides the moment a second integration numbers its bookings from 1, and external_source alone matches every card that system ever raised. A body carrying neither gets no protection — that is the cost of not setting them. Served by AITONE_WKROFILE_EXTSRC; ?allow_duplicate_source_id=1 overrides.
json — a service booking with one job and two lines
{ "branch": "M0101", "reg": "ABC123GP", "driver": "40118",
  "req_datetime": "2026-09-11 07:30:00",
  "odometer": 148230, "salesman": "1042",
  "customer_note": "Rattle from front left over bumps",
  "external_source": "ServiceConnect",
  "external_source_id": "bk_9f3a21",
  "jobs": [
    { "job_code": "SERVICE", "job_type": "R",
      "description": "60 000 km major service",
      "est_hours": 2.5, "est_labour": 2375.00,
      "lines": [
        { "detail": "Replace oil and filter", "value": 890.00 },
        { "detail": "Investigate front-left rattle", "value": 0.00 } ] } ] }
// expected_datetime follows req_datetime when only one is sent — half this API
// dates a booking by one column and half by the other.
json
{ "status": "ok", "created": true,
  "branch": "M0101", "ro_number": 10234,
  "jobs": [ { "job_code": "SERVICE", "job_type": "R",
             "job_type_label": "Retail",
             "lines": [ { "line_no": 1, "detail": "Replace oil and filter", "value": 890 },
                        { "line_no": 2, "detail": "Investigate front-left rattle", "value": 0 } ] } ],
  "lines": 2,
  // a RETRY of the same booking answers 200 with created:false instead, and
  // writes nothing: { "status":"ok", "created":false, "duplicate":true,
  //   "ro_number":10234, "existing":{…}, "message":"A job card was already…" }
  // the reg the card was FILED under, normalised — plus reg_sent when it differs
  "reg": "KZP418MP", "reg_sent": "KZP 418 MP", "reg_filed": "KZP418MP",
  // what this call brought into existence besides the card. {} when the car
  // and the person were both already on file, which is the ordinary case.
  // ALWAYS present: supplied | matched | created | none — also_created only
  // says what was WRITTEN, so a matched customer and no customer look alike
  "customer": { "contact_code": "THABOMASIL41822", "basis": "created" },
  // vehicle_driver_filled appears instead when the car was already on file with
  // a null DRIVER and this card supplied one
  "also_created": { "vehicle": { "reg": "KZP418MP", "driver": "THABOMASIL41822", "created": true },
                    "customer": { "contact_code": "THABOMASIL41822",
                                   "name": "THABO MASILELA", "created": true } },
  // what was taken, and what the next caller will get
  "counter": { "claimed": 10234, "next": 10235, "sys_key": "Next RO Number" },
  "meta": { "transactional": true, "measured": false,
            "line_no_basis": { "basis": "one",
              "evidence": "all 400 sampled job groups start at LINE_NO 1" },
            // the full booking row is not echoed: it is 120 lines of projection
            // living in service_bookings.php, and a second copy would drift
            "note": "Read it back with service_bookings.php?ro_number=10234" } }
GET workshop.php ?ro_attributes=1 &branch= &ro_number= | &ro_numbers=

Every attribute on a repair order — the read half of Set an RO Attribute. Without it that write is a hole you can put a note into and never get back, which is what shipped first and should not have. No write key; this is an ordinary read.

Two shapes, one mode. ?ro_number= is one card — the detail screen. ?ro_numbers= is a comma list, which is how a board asks: a list showing twenty jobs wants to know which of them carry a note, and twenty calls to find out is how a screen becomes slow in a way nobody attributes to this endpoint. Capped at 200, because the point is the page you are showing rather than the workshop. Repeated numbers are collapsed, so a lazily-built list costs nothing extra.

Every RO you ask about comes back in meta.per_ro, including the ones with nothing on them. A caller merging this into a list needs asked, and there is none to be distinguishable from did not ask — otherwise a missing row reads as an empty note and a failed fetch reads as an empty note, and they are not the same thing.
seq_no on every row is the handle — Set an RO Attribute needs it to edit or delete anything, so a client that drops it can append and never correct. Rows come back ordered by seqNo, which is the order they were written: that is what a reader means by "the notes on this card", and sorting by attrCode instead would interleave a technician's three notes with the advisor's two and lose the conversation.
source says which system wrote each row. Several write notes against one repair order — the workshop app, the service desk, whatever comes next — and any of them may correct any row; it is provenance, not a lock. Rows this API did not create come back too, deliberately: a read that hid them would tell a support engineer the RO has one note when the table holds three. That also makes this the census — nobody has confirmed what wkRoFileAttribute already holds on a given install, and reading a handful of live ROs is the cheap way to see which attrType names are already spoken for.

Not paginated, deliberately. The population is bounded by the ROs you named, and a page marker on a result of four rows is a page marker nobody reads. If one RO ever carries enough attributes to need paging, that is a finding worth reporting rather than a limit to raise.

KeyTypeRequiredDescription
ro_attributesflagreq1 — the mode gate. It shares ?ro_number= with the single-RO read and short-circuits before it, so asking for a card's notes cannot return the whole card.
branchstringreqAn RO is (branch, ro_number) in this DMS, never ro_number alone.
ro_numberintegerreq*One RO. * Either this or ro_numbers. This mode reads the attributes on repair orders you name; it does not scan the table, so one of the two is always required.
ro_numberslistreq*Comma list, max 200. Takes precedence when both are sent. Named separately rather than overloading the singular, because a caller who sends 10234,10235 to a singular parameter should get an error rather than the first number and a silent loss of the second.
attr_typelistoptComma list. Any attrType may exist here, including ones Auto-IT wrote; omit this to see them all.
attr_codelistoptComma list — e.g. technician,alert for a board that shows only flagged notes.
debugflagopt1 = the SQL and timing block.
{ "meta": {
    "branch": "M0101", "ro_numbers": [ 10234, 10235 ], "count": 2,
    // every RO asked about, so "none" and "not asked" cannot look alike
    "per_ro": [ { "ro_number": 10234, "attributes": 2 },
                 { "ro_number": 10235, "attributes": 0 } ],
    "constrained_types": [ "note" ] },
  "data": [
    { "id": 41, "ro_number": 10234,
      "attr_type": "note", "seq_no": 1,
      "attr_code": "technician", "child_code": null,
      "attr_value": "Rear pads glazed, recommend discs at next service.",
      "source": "ServiceConnect Tech",
      "created_by": "pcoetzer", "created_at": "2026-09-06 08:14:00",
      "updated_by": "pcoetzer", "updated_at": "2026-09-06 08:14:00" } ] }
POST workshop.php ?set_ro_attribute=1 &dry_run=1

Writes one attribute against one existing repair order on wkRoFileAttribute — the RO-grain sibling of the WKMECHWKAttribute table that Service Bookings writes for technician allocation. It is where a fact belongs when the DMS has a repair order but no column for the thing you want to record against it. The first such fact is a note on a job card, which the workshop app has needed and had nowhere to put.

The DMS does not read this table. Same trade, stated the same way, as the allocation write one grain down: a row here is real, durable, auditable and visible to every consumer of this API — and the workshop's own screens are not a consumer. A note written here does not appear on an Auto-IT terminal. If what is needed is "the DMS knows", this is not it, and nothing built on top of it will make it so.
It appends. A POST with no seq_no adds a row; it does not overwrite one. The first shape of this write upserted on (branch, ro, attrType, attrCode, childCode), which is right for an attribute a card has one of and wrong for the first thing anybody wanted to store — a technician's second note replaced their first, and reported rewritten so it read as success. seqNo is allocated per (BRANCH, RO_NUMBER, attrType) starting at 1 and comes back on the response. Scoped per type deliberately: per RO would mean adding a photo attribute pushes the next note to 4, and per code would make a technician note and an advisor note both "note 1" so a screen could not order them.
Any attr_type may be written, and there is no ownership guard. Two positions were tried and dropped in one day, recorded here so neither is reinvented. An allow-list refused a legitimate new attribute until a code change shipped and did nothing about a collision on a name that was on the list — a list of names cannot tell you who wrote a row. Ownership by source looked right and was wrong for this table: several systems legitimately write notes against one repair order, and a lock keyed on the writer stops the service desk correcting a note the workshop app filed. What this costs, plainly: nothing stops a caller deleting a row it did not write. Addressing is explicit — you must name a seq_no — so accidental clobbering is gone, but a determined caller is not stopped. If that ever matters the guard belongs on attrType, not on the writer, because the writer is the thing that legitimately varies here.
roAttrTypes() now constrains types rather than permitting them. An entry there gives a type a width cap, a list of valid attr_code values, or a rule about child_code — and a type absent from it is a supported thing to write, not a fallback. Today one type is constrained, and only lightly: note accepts codes technician, advisor and alert, refuses child_code, and takes the column's full 8 192 characters. It was capped at 4 000 for a day on the guess that a note is a sentence — a technician writing up a diagnosis is not writing a sentence, and a cap invented here would truncate the one note somebody needed to read in full. Names are still shape-checked — letters, digits, underscore, dot, hyphen, starting with a letter or digit — because both columns are CHAR(15) and part of the key, and a value with a space or a quote in it is a row written once and never matched again.
Nine columns are NOT NULL DEFAULT NULL, which means no usable default — an insert that omits any of them fails outright. Two of them the allocation write never sets: attrCode, which is part of the key here rather than an optional label, and insId/updId, an operator username that must be supplied. There is no CV_USERNAME connection default to fall back on the way Lop_Id has, and that is the better outcome: this table cannot silently attribute a row to whatever account the API happens to run as. It defaults to One Api — this API, named as itself. Send set_by to name the human instead. What it will not do is invent a person.

Four gestures, decided by seq_no and attr_value: no seq_no with a value appends at the next ordinal · a seq_no with a value rewrites that row · a seq_no with attr_value: null deletes it · no seq_no with null is a 400, because a card may carry nine notes and guessing which to delete is not an option. Writing values a row already holds is not a write: nothing moves and the action comes back unchanged, so a re-render cannot churn every consumer that syncs on updDt. Every field you can change is compared, not just the text — promoting a note from technician to alert is a real change.

The ordinal is claimed inside a transaction, because read-then-insert is a race and two technicians adding a note to the same card at the same moment is not hypothetical on a shop floor. Unlike Create a Job Card, which refuses outright when the driver will not release autocommit, this degrades and says so: ID is the real primary key and autoincrement, so a duplicated seqNo costs nothing structural — two notes share an ordinal until somebody edits one. Refusing to record a technician's note because of a driver setting would be the worse trade. transactional: false and a warning ride on the response when that happens.

Over-length values are refused, not truncated. attrType, attrCode and the operator are CHAR(15), childCode is VARCHAR(100), attrValue is VARCHAR(8192) and no type caps below it today. A key cut short at fifteen characters is a row the caller that wrote it will never find again — so the response names the field and the limit rather than letting the column decide.

KeyTypeRequiredDescription
set_ro_attributeflagreq1 — the mode gate. A POST to workshop.php without it is the create or the progress-status write; none of the three is inferred from the body, because a typo would then choose a different write against a different table.
dry_runflagopt1 = the statement and its bound params, and the before → after. helpers' own dryRun() reports only the SQL; the question asked before a write is what it will do to the row, and nine question marks do not answer it.
branchstringreqBody. An RO is (branch, ro_number) in this DMS, never ro_number alone.
ro_numberintegerreqBody. Must exist on WKROFILE — a 404 otherwise, rather than an attribute row against nothing that no read will ever surface.
attr_typestringreqBody. Any name, up to 15 characters — letters, digits, _, ., -, starting with a letter or digit. Types named in roAttrTypes() carry extra rules; everything else takes the defaults.
attr_codestringreqBody. Part of the key, not a label — a type with a single member still names it. Same shape rules. Constrained only where the type says so: for note it must be technician, advisor or alert.
child_codestringoptBody. A third key part, for a type that needs one row per something-within-the-RO. Optional by default; a type may declare it required or refused. note refuses it, so sending one there is a 400 rather than a silently ignored key.
seq_nointegeroptBody. Omit to append — the endpoint allocates the next ordinal and returns it. Send one to edit or delete that row. Allocated per (branch, ro_number, attr_type) from 1. A seq_no that does not exist is a 404.
attr_valuestring|nullreqBody. The payload. null with a seq_no deletes that row; null without one is a 400. Absent is a 400. Writing what the row already holds is a no-op and moves no stamp.
set_bystringreqBody. The person — a technician code, or the username of whoever saved it. CHAR(15), written to insId on an insert and updId on an update. No default: the column is NOT NULL with no usable default, so omitting it would stamp every row in the workshop with the account the API connection runs as (dba_za) — true, useless, and indistinguishable from a row a human authored.
sourcestringreqBody. Which system wrote the row — e.g. "Service Connect Tech", VARCHAR(100), free text. Required, not defaulted: it defaulted to this route for a few hours and every row then answered "who wrote this" with the name of the door they came through, which is no answer. Several systems write against one repair order and any may correct any row, so the row has to say where it came from. Provenance, not a lock.
// an append — no seq_no was sent, so one was allocated and returned
{ "action": "appended", "written": true, "affected": 1,
  "row": { "branch": "M0101", "ro_number": 10234, "attr_type": "note",
           // the handle. Keep it — every later edit or delete needs it
           "seq_no": 2,
           "attr_code": "technician", "child_code": null },
  "before": null, "after": "Rear pads glazed, recommend discs at next service.",
  "operator": "pcoetzer", "source": "ServiceConnect Tech",
  "transactional": true }

// a delete — seq_no named the row, before carries what it held
{ "action": "deleted", "written": true, "affected": 1,
  "before": "Rear pads glazed…", "after": null, /* … */ }

// nothing you sent differs from the row — no stamp moved, and not an error
{ "action": "unchanged", "written": false, "affected": 0, /* … */ }

Work In Progress

The live WIP board — every open repair order (at least one uninvoiced WKOTHSUB line, header not invoiced) with its financial rollup across labour (WKMECHWK), sublet (PURCHASE_CONTROL), other (WKRODESC) and parts (INSALPAR + INMASTER valuation). Each RO is classified as WIP, Quote, No Show or Future Booking, and carries the equipment, sales advisor, last technician and age. The response pairs a ready-to-plot summary (counts by category, value totals, age bands) with the paginated data rows.

GET workshop_wip.php ?branch= &category= &job_type= &job_code= &mechanic_code= &ro_status_include= &sales_advisor_include= &age_ranges= …

Open repair orders with a live value rollup and derived category. The summary block feeds the WIP widgets (value tile, category donut, age-band bar); the data array is the WIP list, filterable and paginated. Every filter is optional; category is validated against the known set, and comma lists are accepted for branch / branch_exclude / franchise / category. The core is job-line grain (branch·ro·job_code·job_type); group_by=ro (default) rolls it up to one row per RO for the widgets, group_by=job returns the raw lines. Since 2026-08-21 ?risk_rollup=1 returns the Workshop Risk tab's two SALES clocking exceptions and nothing else — {meta:{thresholds,grain}, lines:[…]} carrying only the (technician × snapshot document) rows that trip a rule, each tagged rule_hours / rule_nocost. (1) ABS(invoice_hrs) > 500 — a mis-allocation between two technicians on one invoice. The NET is correct and that is the point: doc 2931434 books +8 195.37h against another technician's −8 157.05h, netting +38.32h, so the invoice, the RO and every total read right and nothing else flags it — but technicians are paid on hours sold, so the split is what moves money. The threshold is measured at DOCUMENT grain (p99 65h; largest genuine jobs 110/142/172h; then nothing until 8 157h), not the WIP side's 100h, which is per clocking LINE and would flag those three real rebuilds. (2) cost_val = 0 against sell_val > 0 — a technician with no cost rate: 4 technicians and R2 028 909 over six months on one dealer, R2 015 197 of it a single technician present in every month. It matters MORE since the zero-cost ruling, not less: these rows used to return gross_profit:null and the card printed "cost n/a", which warned the reader by itself. Period-scoped like every Sales card, and both thresholds are absolute and per document so a wider window cannot accumulate a false positive. The RO rollup no longer truncates its money columns. All nine used to be wrapped in floor(), which produced two roundings that could not agree: sum(floor(component)) <= floor(sum(components)), so the four component columns summed to R519 of sales and R765 of cost LESS than the total_sales on the same row — and ?group_by=job_type, which does not truncate, read ~R1 400 above it. Flooring per job line instead cannot fix that: floor-per-line and floor-per-RO are different numbers, so the two grains agree only if neither truncates. Every money column now carries the raw numeric(12,2), the components add to the total by construction, and the job-type aggregate ties to the RO rollup. Cents reach the wire; no card shows them (everything goes through pfmt() at R-thousand or R-million precision). Since 2026-08-21 total_sales on the RO rollup CARRIES THE RO CHARGES. They were added to other_sales and then left out of the total, so that column never equalled the sum of the four component columns the same row reports — measured live, lab + sub + other + part = 95 862 243 against a total_sales of 95 712 600, short by the R149 643 of charges. Nothing flagged it because both figures come off the same row and no card added the components up: Pulse's Revenue Composition draws its slices from the components and its donut centre from total_sales, so its legend has always summed to more than its middle. Charges are real revenue on the RO, so they belong in the total. This raises the WIP total and GP on every card that sums total_sales — +0.16% on the dealer measured. Since 2026-08-21 group_by=job_type aggregates the job lines in SQL to one row per job type × category — the Job Type Composition widget's feed. Two faults in the job-line projection were fixed the same day, and anything that summed that grain before was wrong: it left labour + sublet inside other_sales (the WKRODESC value carries both, and only the RO rollup netted them back out) and added the RO charges to every line rather than once per RO. Summing job lines read R116.9M against the trusted R95.8M on 4 149 open ROs — +22%, of which R21.0M was the double-count and R0.2M the charges. Nothing consumed that grain at the time, so it never showed. Charges are now allocated pro-rata on each line's own sales (equal split where an RO's lines are all zero-valued), so the shares sum to one and the job grain ties to the RO rollup exactly; the per-RO denominator comes from a SUM() OVER (PARTITION BY branch, ro_number) rather than a second pass over the component union. job_code / job_type slice the lines before the rollup (RO value = matching lines only); ?job_facets=1 returns their distinct value lists for the picker. mechanic_code works differently on purpose: technician lives one grain below the RO (on the labour lines, and an RO usually has several), so it is an EXISTS that selects whole ROs — "the ROs this technician has labour on" — and leaves their value alone. Parts, sublet and RO charges cannot be attributed to a technician, so slicing the value would hand back a partial total that read exactly like a real one. Since 2026-08-20 the RO rollup also carries confirmed_status — the RO header's confirmation flag, for the Workshop Risk tab's unconfirmed-WIP finding. It is bound by catalog probe, not by name: SYSCOLUMN is read once per request and the first existing candidate wins (confirmed_status / confirm_status / confirmed_ind / confirmed / booking_confirmed), emitting null when none does, because this column sits in the one query every WIP widget waits on and a wrong name would 500 the whole tab rather than empty one card. meta.confirmed_col reports which column it bound, or null — so a caller can tell an unflagged dealer from an unsupported one. RO grain only (group_by=ro); it is a header attribute and the job-line projection has no grain to carry it at. A second narrow mode, ?risk_rollup=1, returns only the open ROs carrying an invoice that was reversed and never re-raised — work that has quietly come back onto the board with the money already handed back. A reversal returns the RO to WIP (verified live: 12 of 12 flagged were open, while a re-invoiced control was not), and once there nothing distinguishes it from fresh work: its value pads WIP totals and its dates pad job age. The test is per credit note — a credit with no invoice on the same RO dated after it — because an RO can carry several invoices and reversing one of them is exactly as un-re-raised whether or not the others stand. Measured 2026-08-20 over 1 Jun – 20 Aug: 312 credits were replaced by a later invoice, normally within 29 minutes, and 12 were not. Nothing else flags them, which is the whole reason this mode exists.

?claim_ageing=1: "open" is a proxy for "unclaimed", and it may be the wrong one. Nothing this codebase reads records that a warranty claim was submitted. The inference holds if the claim is the invoice — and fails completely if the shop invoices to the manufacturer's account on submission and then waits for payment, because those ROs are already closed and invisible here while the money is still outstanding. So the mode does not assert "unclaimed": it returns the population plus a by_stage rollup of RO_PROGRESS_STATUS across it, because if this dealer tracks claims at all it will be as a progress stage — "Claim Submitted", "Awaiting Claim Approval" — and that rollup is how you find it. Filter on it with ?ro_status_include= and the figure turns from inferred into real. The clock runs on creation_date, never datetime_in, which falls back to GETDATE(): on a mode whose whole subject is a deadline, silently resetting the clock on the worst-documented jobs is the one failure that cannot be allowed. Caveat worth knowing: a manufacturer's window usually runs from the repair date, so this reads a claim as older than they do — conservative, which is the right direction for a deadline, but not exact.
Age is not the same as silence, and ?stalled=1 is the difference. This board ranks open work by how old it is. A 40-day RO being clocked every day is a big job; a 12-day RO nobody has touched since it opened is a job parked behind the workshop with money in it — and the age bands put them in the wrong order. ?stalled=1 returns only the second kind, under three independent rules (never_clocked · clocking_stale · untouched), sorted by stalled_days rather than by value, with the board's own total_sales on every row so the size of what is parked is exact. See the stalled parameter below for the rules and their thresholds.
The financial rollup, category rules and parts-cost valuation are workshop business logic and are preserved verbatim. branch here is the RO branch. The expensive core query runs once per request; the summary and pagination are computed over the returned set.
KeyTypeRequiredDescription
branchstringoptComma list of RO branches to include
branch_includestringoptCSV of RO branches to include (Pulse-standard alias of branch)
branch_excludestringoptComma list of RO branches to exclude
franchisestringoptComma list of franchise codes to include
franchise_includestringoptCSV of franchise codes to include (Pulse-standard alias of franchise)
categorystringoptWIP · Quote · No Show · Future Booking (comma list allowed)
service_typestringoptField or Shop (partial match)
group_bystringoptro (default) = one row per open RO (the widget rollup, with summary) · job = raw job-line detail (per branch·ro·job_code·job_type, no summary) · job_type = server-side aggregate, one row per job type × category in job_types[] with sales / cost / gross_profit and a totals block — the Job Type Composition widget's feed. Job type is a job-LINE attribute, so this grain cannot be served from the RO rollup: an RO carries a LIST of types (ro_type) and no way to divide its value between them. The category rides along so a client can switch the WIP / Quotes / No Show / Future Booking scope without a refetch. Aggregated over the same projection job returns, so every filter behaves identically — and it is server-side because the job-line detail is 5.4 MB for 5 134 lines on one dealer with gzip off on this host, so grouping in the browser would put a second multi-MB pull on a tab that already carries one
job_code / job_code_includestringoptFriendly job-code bucket(s): Sublet · Repair · Service · <raw code> (comma list). Filters the job LINES before the rollup, so the RO's value is the slice of matching lines only.
job_type / job_type_includestringoptFriendly job-type(s): Retail · Fleet · Internal · Warranty · Policy · Sundry · Excess · Project Billing (comma list). Same line-slice semantics as job_code.
job_facetsflagopt1 ⇒ returns {job_codes:[…], job_types:[…]} distinct value lists (branch-scoped) for the Filters-modal pickers, and nothing else.
claim_ageingflagopt1 ⇒ money with a deadline, and nothing else. The RO rollup sliced to the non-charging job types — Warranty, Internal, Policy by default — and banded against a claim window. Warranty work is the shop billing somebody who has a deadline: a manufacturer's claim window closes, a retail invoice never does. So aged non-charging WIP is not just old money like the rest of this board, it stops being collectable on a date, and nothing else here expresses that. Bands: expired · due_soon · safe · unknown, with value on each, plus by_franchise, by_job_type and by_stage. Read summary.by_stage first — see the caveat below. Rows sort most-urgent first; unknown (no usable creation date) sorts last, because a data gap is not an emergency and putting it at the top would bury the real ones.
claim_typesstringoptclaim_ageing only. The friendly job types that mean somebody other than the customer is being billed. Default Warranty,Internal,Policy. Validated against the eight labels this endpoint emits, so a typo is a 400 rather than an empty board — which on this mode would read as "no warranty exposure" and is the most comfortable possible way to be wrong.
claim_daysintegeroptclaim_ageing only. The claim window in days. Default 90. The endpoint cannot know this — it is a term of a franchise agreement, differs per manufacturer and is not in the DMS.
claim_days_by_franchisestringoptclaim_ageing only. Per-manufacturer override as CODE:DAYS pairs — e.g. TY:60,NS:120. A franchise not named falls back to claim_days. Every row reports the claim_days actually applied to it.
due_soon_daysintegeroptclaim_ageing only. How many days left still counts as due_soon rather than safe. Default 30.
stalledflagopt1 ⇒ only the open ROs that nothing is happening to, and nothing else. The board ranks by age, which cannot tell a 40-day job being worked every day from a 12-day job nobody has touched since it opened — and only the second one is anybody's to fix. Three independent rules, one pass, each tagged on the row in stall_rules and counted with its value in meta.rules: never_clocked (no WKMECHWK line on the RO at all, and its slot passed ?never_clocked_days ago — nobody has ever started it) · clocking_stale (it has clocking and the most recent is ?clocking_stale_days old — started, then abandoned) · untouched (the RO header has not been written in the DMS for ?untouched_days — not even opened). Only the first needs a due date, which is the point of splitting them: a job last clocked three weeks ago is stalled whatever its slot said, so the other two anchor on their own timestamps. That due date is deliberately not the datetime_in on the same row — that column falls back to GETDATE(), which is right for an age figure and silently fatal here: an undated RO would read as zero days overdue, so the jobs with the worst paperwork, exactly the ones that get parked, would be the ones this mode cannot see. days_overdue re-derives it as ISNULL(expected_datetime, creation_date). Rows carry stalled_days — the single number to sort and colour by, and where two rules fire it is the smaller, because the more recent sign of life is the honest one. Sorted by it, not by value. Quote and Future Booking are excluded unless ?category= says otherwise. Wraps the RO rollup, so total_sales is the board's own figure to the cent and every filter here applies. Not measured on live — meta.measured is false.
never_clocked_daysintegeroptstalled only. Days past the RO's due date before "nothing clocked yet" counts as stalled. Default 7; 0 fires immediately, which is legitimate on a shop that starts jobs the day they arrive.
clocking_stale_daysintegeroptstalled only. Age of the most recent clocking before the job counts as abandoned. Default 7. Measured on ISNULL(date_clocked_in, start_time), the same two-column basis Technician Work ranges over.
untouched_daysintegeroptstalled only. Days of silence on the RO header before it counts as untouched. Default 14 — longer than the other two, because paperwork silence is weaker evidence than floor silence.
touched_colstringoptstalled only. Pin the WKROFILE last-modified column. Probed as lop_datetime → last_modified_datetime → last_modified_date — the same watermark Service Bookings syncs on. When nothing binds, the untouched rule simply never fires and meta.rules.untouched.available is false: a missing column costs one rule of three, not the mode.
risk_rollupflagopt1 ⇒ only the open ROs carrying an invoice that was reversed and never re-raised, and nothing else. One row per RO: branch / ro_number / franchise / invoices / credit_notes / unreplaced_credits / reversed_value / net_invoiced / whole_ro / last_credit_date / last_invoice_date / days_since_credit, plus meta.as_of (the dealer system's own clock), meta.reversed_value and meta.whole_ro_count / meta.part_ro_count. The rule is per credit note: a credit with no invoice on the same RO dated after it. It is not "has a credit note" (RO 518171 carries six and nets R10 310, perfectly healthy) and not "the RO nets to zero" — that was the wrong grain and missed RO 523993, where invoice 2893865 (R107 682) still stands while invoice 2899876 (R969 140) was reversed by credit 2930579 and never replaced: 4.8× the value of every net-zero case combined. whole_ro says which kind a row is — true = every invoice on the RO was reversed, false = part of it is still invoiced and that part is real work; both are flagged and reversed_value counts only the un-replaced credits, so a credit a later invoice already replaced is excluded as corrected money. Being a date test it is a heuristic: there is no key for "this invoice replaces that credit" (invoice.orig_invoice_no links credit → original only), so new work invoiced after an abandoned reversal would read as a replacement — all 312 healthy cases over 1 Jun – 20 Aug hold the I → C → I' shape. Honours branch / branch_include / branch_exclude / franchise / franchise_include; no date window and no category filter, because the population is "open now" and the oldest cases are exactly what a period window would hide. Feeds the Workshop Risk tab's "Reversed, not re-invoiced" exception, which applies the per-vertical grace period (agricultural 7 days, motor 2) client-side. Driven off aitone_wkinvreg_br_ro, which also serves the per-credit probe.
ro_numberintegeroptA single repair order
ro_number_includestringoptRepair order number(s) — comma list, the multi-pick form of ro_number. Ints only: a non-numeric term is dropped rather than quoted into a comparison that can never be true.
ro_status_includestringoptRO status(es), comma list — matched on the long description exactly as this endpoint returns it (e.g. VIP - Work in progress). Compared untrimmed: ro_status is char(100) in SQL but the ODBC driver strips trailing blanks before any client sees it, so the value a client picked out of a previous response is the value stored.
sales_advisor_includestringoptSales advisor(s), comma list — matched on the Name Surname value exactly as this endpoint returns it.
age_rangesstringoptJob-age bands as explicit day bounds on age_days_datetime_in, OR-ed: 0-7,31-. An open-ended top band omits the upper bound. Bounds rather than the job_age=0_7,… bucket ids that workshop_wip_parts_backorder.php takes, on purpose — that endpoint ages a purchase order and is pinned to the agricultural bands, whereas a workshop job's bands follow the customer's vertical (agricultural 0_7/8_30/31_90/90_plus vs motor 0_0/1_2/3_5/6_10/11_30/31_plus — different ids and different counts). A fixed id vocabulary here would match no case for the other vertical and silently produce no clause at all, so the filter chip would read “Job Age: 3–5 days” while the figures never moved. Bounds keep the one definition of a band in the client's sdAgeBuckets().
mechanic_code / mechanic_code_includestringoptComma list of WKMECHWK mechanic codes — keeps only ROs one of those technicians has a labour line on. Technician is not an RO column (it is one grain down, and an RO usually has several), so this is an EXISTS over the RO's labour lines and the value is not sliced: a matching RO still shows its whole total, not that technician's share of it. An RO with no labour line therefore drops out entirely, and that is a large population — measured 2026-08-11, only 1 671 of 4 092 open ROs carried any labour line, including none of the 602 Quotes — so any technician filter empties the Quote category. Roster for the picker comes from workshop_wip_labour.php?tech_facets=1.
flagstringoptY = future booking that already has value; N = rest
min_total_salesnumberoptOnly ROs with total_sales ≥ this (value-at-risk)
min_ageintegeroptOnly ROs at least N days old (by datetime_in)
searchstringoptreg, RO number or equipment
page / limitintegeroptlimit blank = all rows; number = that many (else 20)
json
{ "meta": { "total": 128, "page": 1, "limit": "all", "total_pages": 1 },
  "summary": {
    "ro_count": 128,
    "by_category": { "WIP": 90, "Quote": 20, "No Show": 8, "Future Booking": 10 },
    "totals": { "lab_sales": 184200, "sub_sales": 42100, "other_sales": 9800,
      "part_sales": 211500, "total_sales": 447600, "total_cost": 310400, "gross_profit": 137200 },
    "age_bands": { "0_7": 61, "8_30": 40, "31_60": 15, "61_90": 7, "90_plus": 5 } },
  "data": [{ "branch": "DBN", "branch_name": "DURBAN", "ro_no": 10234,
    "category": "WIP", "service_type": "Shop", "franchise": "TY", "franchise_name": "Toyota",
    "equipment": "FORD RANGER", "reg_no": "CA123456", "ro_status": "In Workshop",
    "sales_advisor": "Jane Doe", "last_tech": "John Smith",
    "total_sales": 4235, "total_cost": 2980, "age_days_datetime_in": 3, "flag": "N" }] }

WIP Labour Detail

The labour-grain drill-down behind Work In Progress — one row per WKMECHWK labour line on an open RO (same WIP gate: uninvoiced WKOTHSUB job + header not invoiced). Each line carries the mechanic (contact), booked vs worked hours, job-code estimate (WkCodeFl), sell/cost values and derived sell_rate / cost_rate / eff_perc. The response pairs a summary (by-mechanic rollup + weighted totals) for the technician-productivity widgets with the paginated data lines.

GET workshop_wip_labour.php ?ro_number= &mechanic_code= &tech_facets=1 &risk_rollup=1 &work_location= &ro_status= &sales_advisor= &job_code= …

Per-line labour detail for open ROs. Pass ?ro_number= for a single-RO drill-down, or filter across the whole WIP by branch / franchise / mechanic / work location / job code / job type. The summary.by_mechanic rollup feeds the technician-efficiency widgets; efficiency_pct is booked ÷ worked hours, and the rates are weighted (Σvalue ÷ Σhours). category is the SAME 4-way value as workshop_wip.php (WIP / Quote / No Show / Future Booking), and job_code / job_type are the same friendly labels — so a tab-wide Job Code / Job Type filter matches on both endpoints. Every line also carries the RO header's ro_status and sales_advisor: the RO number is the key both endpoints already share, and WKROFILE is joined here anyway for the open-RO gate, so two lookup joins off it put the board's own Status and Sales Advisor on every labour line — filterable here, and value-identical to workshop_wip.php because the expressions are copied verbatim. This endpoint also owns the technician roster (?tech_facets=1): it is the only WIP feed that carries a technician at all, so every other WIP card's Technician picker is fed from here, and the same pick means "this technician's lines" here but "the ROs those lines sit on" everywhere else. A second narrow mode, ?risk_rollup=1, returns only the clocking lines that cannot be real under three independent rules — an impossible booked figure, a line spanning a whole calendar day, or clocking dated in the future. The Workshop Risk tab needs those findings but must not pull this 4.3 MB feed to get them, and none of them breaks any arithmetic (a corrupt line destroys a technician's efficiency while the tab total survives; 22 whole-day lines add R643 711.86 of WIP value while leaving efficiency untouched), so nothing else flags them.

Rate / efficiency divisions are guarded (zero-hours line → 0, never a divide-by-zero). A labour line matched to more than one open WKOTHSUB sub-line can appear more than once — verify against your WKOTHSUB grain if exact de-duplication matters.
KeyTypeRequiredDescription
ro_numberintegeroptA single repair order (drill-down)
branchstringoptComma list of RO branches to include
branch_includestringoptAlias of branch (Pulse multi-select CSV)
branch_excludestringoptComma list of RO branches to exclude
franchisestringoptComma list of franchise codes to include
franchise_includestringoptAlias of franchise (Pulse multi-select CSV)
ro_statusstringoptRO progress status(es) — comma list of the CODTYP 'WC' descriptions the WIP board shows in its Status column. Carried onto every labour line via the RO join, so a value picked on a WIP-RO card matches here verbatim. Matched on the trimmed value: this DB is not blank-padded, and at least one live description carries a real trailing space, so Claim Warranty is selectable as Claim Warranty.
ro_status_includestringoptAlias of ro_status (Pulse multi-select CSV)
sales_advisorstringoptSales advisor(s) — comma list of Name Surname, exactly as the board renders them
sales_advisor_includestringoptAlias of sales_advisor (Pulse multi-select CSV)
mechanic_codestringoptOne or more technician / mechanic codes (comma list). This endpoint is line-grain, so it matches its own column — "this technician's lines". The other WIP feeds have no technician column and use an EXISTS over the RO's labour lines instead, i.e. "the ROs they have labour on". Matched on the trimmed column, same reason as ro_status.
mechanic_code_includestringoptAlias of mechanic_code (Pulse multi-select CSV)
tech_facetsflagopt1 ⇒ returns {technicians:[{mechanic_code, mech_name}]} and nothing else — the roster for the tab-wide Technician picker, which every other WIP card is fed from because this is the only endpoint that knows it. Derived from the same core query as the detail (not a raw WKMECHWK scan), so it can never offer a technician whose only work sits on ROs this feed excludes. Honours every filter except mechanic_code, so the picker never narrows itself.
risk_rollupflagopt1 ⇒ returns {meta:{hours_threshold, day_hours_threshold, as_of, line_count, technician_count, ro_count, rules:{…}}, lines:[…]} and nothing else — only the clocking lines that cannot be real, each tagged in risk_rules with the rule(s) it trips. Three independent rules, one pass — booked (ABS(invoice_hrs) > 100, a fat-fingered number: RO 550965 books +8 705.18h on one line against another technician's −8 141h) · wholeday (hours_work >= 23.5, a clock left running: RO 549225 carries 22 lines of 00:00→23:59 on 22 consecutive days, six of them weekends, while the job sat at "Delay Customer Approval" — 527.6h and R643 711.86 of WIP value that is not work) · future (start_time > GETDATE(), work not yet done but already booked and valued, furthest 2027-07-14). Independent predicates, not a priority chain: a line tripping two counts in both, exactly as the backorder rollup's three do. meta.rules carries per-rule lines / technicians / ros / sell_val. Neither hours rule can see the other's fault, which is why there are two: hours_work is derived from start→finish inside one day so it can never exceed ~24, while invoice_hrs is keyed by hand and can hold anything. Both thresholds need no judgement — for booked the 99th-percentile is 13h and the next value up is 510h (a 497-hour gap); for wholeday the feed-wide p50 is 2.5h, p90 8h, p99 12h, the longest genuine line is 16.92h and there is nothing at all between 17h and 23.5h. GETDATE() rather than a PHP timestamp so future is judged on the database's own clock; meta.as_of echoes it back off the same statement. A legitimate Future Booking cannot false-positive: that category carries zero clocking lines (verified across 4 150). Returns the lines themselves, not an aggregate — dozens of them, so every exception's drill is served by this one response with no second fetch (unlike the backorder rollup, which has thousands). Honours every filter; no category and no job-type filter, deliberately — a corrupt line needs correcting whichever bucket it sits in, Internal included.
work_locationstringoptShop or Field (accepts S / F)
job_codestringoptFriendly job-code bucket(s): Sublet / Repair / Service / raw code (comma list)
job_code_includestringoptAlias of job_code (Pulse multi-select CSV)
job_typestringoptFriendly job-type(s): Retail / Fleet / Internal / Warranty / Policy / Sundry / Excess / Project Billing
job_type_includestringoptAlias of job_type (Pulse multi-select CSV)
searchstringoptmechanic name, RO number or job code
page / limitintegeroptlimit blank = all rows; number = that many (else 20)
json
{ "meta": { "total": 342, "page": 1, "limit": "all", "total_pages": 1 },
  "summary": {
    "line_count": 342, "mechanic_count": 11,
    "totals": { "hours_work": 1284.5, "invoice_hrs": 1102.0,
      "sell_val": 742100.00, "cost_val": 388600.00, "gross_profit": 353500.00,
      "efficiency_pct": 85.79, "sell_rate": 673.41, "cost_rate": 302.53 },
    "by_mechanic": [{ "mechanic_code": "1042", "mech_name": "John Smith",
      "lines": 48, "hours_work": 168.0, "invoice_hrs": 152.5,
      "sell_val": 102400.00, "cost_val": 50800.00, "gross_profit": 51600.00,
      "efficiency_pct": 90.77, "sell_rate": 671.48, "cost_rate": 302.38 }] },
  "data": [{ "branch": "DBN", "franchise": "TY", "ro_number": 10234, "category": "WIP",
    "job_code": "Service", "job_type": "Retail", "work_cat": "1", "mechanic_code": "1042",
    "work_location": "Shop", "mech_name": "John Smith", "seq": 1,
    "hours_work": 3.5, "job_code_hrs": 3.0, "invoice_hrs": 3.0,
    "sell_val": 2010.00, "cost_val": 1058.75,
    "sell_rate": 670.00, "cost_rate": 302.50, "eff_perc": 85.71 }] }

WIP Sublet Detail

The sublet-grain drill-down behind Work In Progress — one row per PURCHASE_CONTROL sublet line on an open RO (same WIP gate: uninvoiced WKOTHSUB job + header not invoiced). Each line carries the vendor (APMASTER_CONTACT), purchase order number/description & status, vendor-invoice reference, sublet sell/cost value and derived gross_profit / margin_perc. Zero-value lines are excluded. The response pairs a summary (by-vendor rollup + totals) for the sublet widgets with the paginated data lines.

GET workshop_wip_sublet.php ?ro_number= &vendor_no= &service_type= &ro_status= &sales_advisor= &mechanic_code= &job_code= …

Per-line sublet detail for open ROs. Pass ?ro_number= for a single-RO drill-down, or filter across the whole WIP by branch / franchise / vendor / service type / PO status / job code / job type. The summary.by_vendor rollup feeds the sublet widgets; margin_perc is gross profit ÷ sell value, and comma lists are accepted for branch / franchise / vendor_no / category. category is the SAME 4-way value as workshop_wip.php (WIP / Quote / No Show / Future Booking), and job_code / job_type are the same friendly labels — so a tab-wide Job Code / Job Type filter matches on both endpoints. (job_type replaces the old raw ro_type column.) Every line also carries the RO header's ro_status and sales_advisor, resolved through the RO number the two endpoints share (see WIP Labour Detail). Note sales_advisor is the RO advisor and is not po_creater, who is whoever raised the sublet purchase order — different people, different source. mechanic_code is the tab-wide Technician filter: a sublet line has no technician of its own, so it resolves by EXISTS over the RO's labour lines — "the sublet sitting on the ROs this technician is working".

The margin division is guarded (a cost-only line where sell = 0 → 0, never a divide-by-zero — the source query divided by sell directly). branch here is the RO branch. Lines with both sell and cost of 0 are excluded, matching the source.
KeyTypeRequiredDescription
ro_numberintegeroptA single repair order (drill-down)
branchstringoptComma list of RO branches to include
branch_includestringoptAlias of branch (Pulse multi-select CSV)
branch_excludestringoptComma list of RO branches to exclude
franchisestringoptComma list of franchise codes to include
franchise_includestringoptAlias of franchise (Pulse multi-select CSV)
ro_statusstringoptRO progress status(es) — comma list of the CODTYP 'WC' descriptions the WIP board shows in its Status column. Carried onto every sublet line via the RO join, so a value picked on a WIP-RO card matches here verbatim. Matched on the trimmed value: this DB is not blank-padded, and at least one live description carries a real trailing space, so Claim Warranty is selectable as Claim Warranty.
ro_status_includestringoptAlias of ro_status (Pulse multi-select CSV)
sales_advisorstringoptSales advisor(s) — comma list of Name Surname. This is the RO advisor, not po_creater, who is whoever raised the sublet purchase order.
sales_advisor_includestringoptAlias of sales_advisor (Pulse multi-select CSV)
mechanic_code / mechanic_code_includestringoptComma list of WKMECHWK mechanic codes — keeps only the sublet sitting on ROs one of those technicians has a labour line on. A sublet line has no technician of its own (the work is off-site, done by the vendor), so this is an EXISTS over the RO's labour lines; line values are not sliced. Same shared definition the RO board uses, so one pick means the same thing on both.
vendor_nostringoptOne or more creditor / vendor account numbers (comma list)
service_typestringoptShop or Field (accepts S / F)
purchase_statusstringoptOpen or Closed (the sublet PO status)
categorystringoptWIP · Quote · No Show · Future Booking (comma list)
job_codestringoptFriendly job-code bucket(s): Sublet / Repair / Service / raw code (comma list)
job_code_includestringoptAlias of job_code (Pulse multi-select CSV)
job_typestringoptFriendly job-type(s): Retail / Fleet / Internal / Warranty / Policy / Sundry / Excess / Project Billing
job_type_includestringoptAlias of job_type (Pulse multi-select CSV)
searchstringoptvendor name, RO number, purchase no/desc or job code
page / limitintegeroptlimit blank = all rows; number = that many (else 20)
json
{ "meta": { "total": 86, "page": 1, "limit": "all", "total_pages": 1 },
  "summary": {
    "line_count": 86, "vendor_count": 14,
    "totals": { "sublet_val": 142800.00, "sublet_cost": 101450.00,
      "gross_profit": 41350.00, "margin_pct": 28.96 },
    "by_vendor": [{ "vendor_no": "A0012", "vendor_name": "ACME EXHAUST CO",
      "lines": 12, "sublet_val": 31200.00, "sublet_cost": 22100.00,
      "gross_profit": 9100.00, "margin_pct": 29.17 }] },
  "data": [{ "branch": "DBN", "franchise": "TY", "ro_number": 10234,
    "category": "WIP", "job_code": "Sublet", "job_type": "Retail", "service_type": "Shop",
    "purchase_no": 50021, "purchase_desc": "EXHAUST REPAIR", "purchase_status": "Open",
    "vendor_no": "A0012", "vendor_name": "ACME EXHAUST CO",
    "vend_invoice_no": "INV-88231", "vend_invoice_date": "2026-07-14",
    "sublet_val": 2600.00, "sublet_cost": 1840.00,
    "gross_profit": 760.00, "margin_perc": 29.23 }] }

WIP Parts on Backorder

The parts-requirement drill-down behind Work In Progress — one row per INSALPAR sales-order part line linked to an open RO (wkrofile.part_order_no = insalpar.file_no, RO not invoiced). Each line splits the committed qty (comm_part_qty) into what is already on a purchase order (po_qty, capped at the committed qty) and what still has to be ordered (to_order_qty), each valued at the part's derived unit_cost (moving-average cost, else replacement / stock-order price). Carries InMaster description & bin, the salesperson (CONTACT) and the same Job Age columns as the labour / sublet lines. Three response shapes: ?group=1 returns a compact branch × franchise rollup aggregated in SQL (the Pulse widget's main view — a few hundred cells, not the 28k-line board); ?risk_rollup=1 returns the Workshop Risk tab's three backorder exceptions, also aggregated in SQL; without either you get the per-line data + a by-branch summary, which is only ever requested drill-scoped to a single branch+franchise. The core query drives from WkRoFile (ROs that carry a parts order) and seeks INSALPAR by file_no, so it never scans all of INSALPAR. Never pull the whole board: measured live it is 28 459 rows / 35.6 MB / 13.6 s, and ?limit does not bound the query — pagination slices after the fetch, so a capped page costs the same DMS work.

GET workshop_wip_parts_backorder.php ?group=1 &risk_rollup=1 &risk_lines= &branch= &job_age= &part_no= &ro_status= &sales_advisor= …

?group=1 → the compact branch × franchise rollup (drives the widget's rollup levels + KPIs). ?risk_rollup=1 → the Workshop Risk tab feed: the three backorder exceptions as counts + values + contributing-group tables for both drill directions, the two fences and their populations, and a worst-10 context list per fenced rule — all aggregated off the same #bo temp, with ?risk_lines=aging|over|novendor serving its drill leaf from the detail mode. ?parts_like=<q> → distinct part_no/part_desc matching a prefix (feeds the Part filter's type-to-search). ?part_franchises=1 → distinct part_franchise (+ name) for the Part-Franchise picker. ?cat_counts=1 → distinct RO count per category ({counts,total}) for the Options scope badges. ?category= filters any mode to WIP/Quote/No Show/Future Booking (also emitted on the rollup). ?order_state= filters to on_order/to_order/over — per line, the outstanding requirement (order_qty − supplied_qty) vs the raw po_qty; also a rollup grouping dimension carrying over_value. ?no_vendor=1 isolates at-risk lines — no default vendor (insalpar.PO_DEFAULT_VENDOR null) and still short of a PO — also a rollup dimension. Otherwise → per-line detail (drill-scoped). Filters (branch/franchise/part_franchise/ro_type/part_no/job_age/mechanic_code) apply server-side to every mode — mechanic_code being the tab-wide Technician filter, which a part line cannot carry and so resolves by EXISTS over the RO's labour lines ("which of this technician's jobs are stuck waiting for parts"); to_order_qty = committed − on-PO and to_order_value = that qty × unit_cost. The rollup also carries a per-cell risk_rule (aging/over/novendor/'') and echoes the two Tukey thresholds in meta.risk_thresholds, so the Pulse widget lights Risk flames straight off the rollup (no second line-level fetch). The fences are computed here in PHP — a byte-for-byte port of the Workshop Risk tab's tukeyFence (floors 7d / R2000) over the header scope (risk_branch/risk_franchise) — so a flame always maps to a real Risk exception. The core is materialised once into a #bo temp table shared by the rollup + both fence scans; the drill leaf re-uses the echoed thresholds (age_thr/over_thr) to flag its own lines.

The unit_cost division is guarded (a zero on-hand line falls back to the replacement / stock-order price, never a divide-by-zero). po_qty is capped at the committed qty so an over-ordered PO can never drive to_order_qty negative. branch is the RO branch. Two franchises per line: franchise = wkrofile.FRANCHISE (the job / RO franchise, what the rollup groups by and the Franchise filter acts on); part_franchise = i.FRANCHISE (the part's franchise, named via the Parts codtyp TYPE='FC' lookup).
KeyTypeRequiredDescription
ro_numberstringoptRepair order number(s) — comma list (a single value behaves as before); ro_number_include alias
ro_statusstringoptRO progress status(es) — comma list of the CODTYP 'WC' descriptions the WIP board shows; ro_status_include alias. Matched on the trimmed value, so Claim Warranty is selectable as Claim Warranty.
sales_advisorstringoptRO sales advisor(s) — comma list of Name Surname; sales_advisor_include alias. This is the RO advisor, not the salesperson column (who raised the parts sales order).
branchstringoptComma list of RO branches to include
branch_includestringoptAlias of branch (Pulse multi-select CSV)
branch_excludestringoptComma list of RO branches to exclude
franchisestringoptComma list of job / RO franchise codes (wkrofile.FRANCHISE) to include
franchise_includestringoptAlias of franchise (Pulse multi-select CSV)
mechanic_code / mechanic_code_includestringoptComma list of WKMECHWK mechanic codes — keeps only backorders on ROs one of those technicians has a labour line on ("which of this technician's jobs are stuck waiting for parts"). A part line has no technician, so this is an EXISTS over the RO's labour lines; line values are not sliced. Honoured by every facet mode too — it is never a field a facet feeds.
part_franchisestringoptComma list of part franchise codes (i.FRANCHISE) to include; part_franchise_include alias
ro_typestringoptOne or more RO types (ret_war_pol; comma list; ro_type_include alias)
part_nostringoptExact part numbers to include (comma list; part_no_include alias)
job_agestringoptJob-age buckets: 0_7 · 8_30 · 31_90 · 90_plus (comma list)
job_codestringoptFriendly job-code bucket(s): Sublet / Repair / Service / raw code (comma list; same labels as workshop_wip.php; job_code_include alias)
job_typestringoptFriendly job-type(s): Retail / Fleet / Internal / Warranty / Policy / Sundry / Excess / Project Billing (job_type_include alias)
groupintegeropt1 = branch × franchise rollup instead of per-line data
parts_likestringoptPrefix → distinct part_no/part_desc (Part picker search)
part_franchisesintegeropt1 = distinct part_franchise (+ part_franchise_name) for the Part-Franchise picker
ro_facetsintegeropt1 = distinct ro_numbers / ro_statuses / sales_advisors for the three RO pickers, in one round trip. Honours every filter except those three, so no picker narrows itself or its siblings. Uncapped by design — a silent TOP n would read as "that RO has no backorders".
categorystringoptRO category filter: WIP · Quote · No Show · Future Booking (comma list; derived identically to workshop_wip.php / workshop_wip_labour.php / workshop_wip_sublet.php — No Show/Future Booking = the RO's other + parts sales = 0). Also emitted on the rollup rows.
cat_countsintegeropt1 = distinct RO count per category {counts:{…}, total} for the Options scope badges. Honours every filter except category (facets across categories); RO identity = branch+ro_number so a part spanning franchises counts once.
order_statestringoptOn/to/over-order state filter: on_order · to_order · over (comma list). Per line, req_qty (order_qty − supplied_qty) vs the raw po_qty. Also emitted on the rollup as a grouping dimension (with over_value).
no_vendorintegeroptOrderability filter: 1 = at-risk lines — no default vendor (insalpar.PO_DEFAULT_VENDOR null/blank) and still short of a PO (can't be DMS-auto-ordered); 0 = the rest. Fully-ordered no-vendor lines carry no risk so aren't flagged. Also a rollup grouping dimension.
risk_branchstringoptRollup only. The header branch scope (F9-independent) the Risk-flame Tukey fences are computed over — kept separate from branch_include (which may be narrowed by the widget's own filters) so the fence population matches the Workshop Risk tab. Falls back to branch_include/branch.
risk_franchisestringoptRollup only. The header job-franchise scope for the fence population (see risk_branch). Falls back to franchise_include/franchise.
risk_rollupintegeropt1 = the Workshop Risk tab feed: the three backorder exceptions (aging · over · novendor) as count + value + contributing-group tables for both drill directions (groups_branch / groups_franchise), plus meta.risk_thresholds, meta.populations and a worst-10 fallback list per fenced rule. Aggregated in SQL off the same #bo temp as the rollup, so membership matches the widget flames exactly. Replaces the tab's old whole-board ?limit=0 pull (28k rows / 35.6 MB / 13.6 s → KBs). The three rules are independent predicates, not the priority risk_rule CASE — a no-vendor line that is also an age outlier counts in both.
risk_linesstringoptDetail only. aging · over · novendor — restrict the per-line list to the lines flagged by ONE rule (the Risk tab's drill leaf). Uses the same predicate as risk_rollup, so a leaf always sums back to the total it was drilled from. Pass age_thr/over_thr with it; without them a fenced rule returns nothing (never everything). Unknown value → 400.
age_thrnumberoptDetail only. The aging Tukey threshold (in days) the rollup returned in meta.risk_thresholds.aging, echoed back so drill lines get the SAME risk_rule without re-materialising the fence population. Absent = no aging flags.
over_thrnumberoptDetail only. The over-order Tukey threshold (rand value) from meta.risk_thresholds.over, echoed back for per-line flagging. Absent = no over-order flags.
fieldsstringoptDetail only. export = project the ~33 core columns down to the 16 the Pulse export workbook needs (its 13 columns + the 3 quantities summary sums), dropping risk_rule. The export is the one caller that legitimately needs every LINE (28k of them), so w.* more than doubled its payload for columns the workbook never opens. Any other value = full w.*.
searchstringoptpart no/desc, RO number, sales-order no or salesperson
page / limitintegeroptlimit blank = all rows; number = that many (else 20)
json
// ?group=1 — branch × franchise rollup (the widget's main view)
{ "meta": { "mode": "rollup", "cells": 257,
    // Tukey thresholds (header scope) — echoed to the drill leaf as ?age_thr / ?over_thr
    "risk_thresholds": { "aging": 34, "over": 4200.00 } },
  "rollup": [{ "branch": "DBN", "franchise": "TY", "part_franchise": "TY", "part_franchise_name": "Toyota",
    "lines": 28, "ro_count": 12, "part_count": 24, "po_value": 9100.00, "to_order_value": 5010.00,
    // per-cell Risk-flame rule (folded to a branch/franchise flame client-side); '' = none
    "risk_rule": "aging" }] }

// ?risk_rollup=1 — the Workshop Risk tab feed (replaces the old whole-board ?limit=0 pull)
{ "meta": { "mode": "risk_rollup", "cells": 188,
    "risk_thresholds": { "aging": 34, "over": 4200.00 },
    // fence POPULATIONS (all committed lines in that state, pre-threshold) — the cards quote these
    "populations": { "unordered": 9142, "over": 2210 } },
  // one block per rule; the three are INDEPENDENT (a line can appear in aging AND novendor)
  "rules": { "aging": { "count": 412, "value": 1840233.55,
      // branch groups come back name = code (branch names live in gauge.branches, not the DMS)
      "groups_branch": [{ "name": "DBN", "sub": "DBN", "n": 88, "val": 402118.20, "pct": 21.8 }],
      "groups_franchise": [{ "name": "Toyota", "sub": "TY", "n": 140, "val": 655900.10, "pct": 35.6 }],
      "branches": ["DBN", "JHB"] },
    "over": { "count": 51, "value": 288400.00, "groups_branch": [], "groups_franchise": [], "branches": [] },
    "novendor": { "count": 96, "value": 140120.75, "groups_branch": [], "groups_franchise": [], "branches": [] } },
  // worst 10 lines per fenced rule — shown as context when that rule has NO outlier
  "fallback": { "aging": [{ "branch": "DBN", "part_no": "90915-YZZD2", "ro_number": 344843,
      "sales_order_days": 211, "to_order_value": 1820.40, "over_value": 0, "no_vendor": 1 }],
    "over": [] } }

// ?risk_lines=aging&age_thr=34&branch_include=DBN&limit=0 — that group's flagged lines (the Risk leaf)
{ "meta": { "total": 88 }, "data": [{ "part_no": "90915-YZZD2", "risk_rule": "aging", "…": "…" }] }

// per-line detail (no group; the widget requests this drill-scoped to one branch+franchise)
{ "meta": { "total": 64, "page": 1, "limit": "all", "total_pages": 1 },
  "summary": {
    "line_count": 64, "ro_count": 37, "part_count": 58,
    "totals": { "order_qty": 214.0, "supplied_qty": 150.0, "comm_part_qty": 64.0,
      "po_qty": 41.0, "to_order_qty": 23.0, "po_value": 18420.00, "to_order_value": 10360.00 },
    "by_branch": [{ "branch": "DBN", "lines": 28, "comm_part_qty": 31.0,
      "po_qty": 20.0, "to_order_qty": 11.0, "po_value": 9100.00, "to_order_value": 5010.00 }] },
  "data": [{ "branch": "DBN", "ro_number": 10234, "franchise": "TY",
    "part_franchise": "TY", "part_franchise_name": "Toyota",
    "part_no": "90915-YZZD4", "part_desc": "OIL FILTER", "ro_type": "R", "bin_location": "A-12-3",
    "sales_order_no": 55021, "sales_order_date": "2026-07-10", "sales_order_days": 17,
    "salesperson": "Jane Doe", "order_qty": 4, "supplied_qty": 1, "comm_part_qty": 3,
    "po_qty": 2, "to_order_qty": 1, "unit_cost": 112.40,
    "po_value": 224.80, "to_order_value": 112.40,
    "datetime_in": "2026-07-10 08:15", "age_days_datetime_in": 17 }] }

Workshop Sales

The invoiced counterpart to Work In Progress — one row per time bucket × job code × job type, off WKINVREG (the invoice one-liner, work_date = invoiced date) with the sale elements split by job line from the ZA_WKOTHSUB snapshot and technician hours from ZA_WKMECHWK. Rolls up by day, week-within-month, month or year. Same filter vocabulary as the WIP endpoints, so one Workshop filter bar drives both.

GET workshop_sales.php ?date_from= &date_to= &months= &bucket= &group_by= &branch= &job_code= &job_age= &band= &technician= &risk_rollup= &reinvoice_variance= &variance_floor= …

Server-side aggregate — the payload is bounded by the bucket count, never by the invoice count. bucket chooses the time grain (day · week · month · year, where week is the week WITHIN the month, 1–5) and group_by the dimension carried alongside it (job = job_code × job_type, the default; or branch / franchise / advisor / none). Every row carries labour_sales / parts_sales / other_sales / sublet_sales + total_sales, and — for the labour element — invoice_hrs, hours_work, labour_cost and labour_gp. Gross profit is COMPLETE: labour_cost from ZA_WKMECHWK clocking, parts_cost and sublet_cost from INVOICE (parts_cost_val / sublet_cost_val, document+RO grain, split across job lines by each element own sale), and other_cost a hard 0 since Other has no cost of sales. So labour_gp / parts_gp / sublet_gp / other_gp and — for the first time — total_gp. Plus the three document counts ro_count / invoice_count / credit_note_count. job_code / job_type are the SAME friendly labels as workshop_wip.php, so a tab-wide Job Code / Job Type filter matches on both.

Totals always tie back to WKINVREG. Where the ZA_WKOTHSUB snapshot does not fully account for a document (ROs predating the mirror, or a partial snapshot), the remainder is emitted as a synthetic job_code: "Unallocated" row with allocated: false, and reported in summary.reconciliation — so a snapshot gap is visible rather than a quiet shortfall. This residual is suppressed when a job_code / job_type filter is active (you asked for a slice, so the rest of the document is not "unallocated"); summary.reconciliation.ties_to_wkinvreg says which mode you are in.
The three document counts have two rules, and both bite. ro_count / invoice_count / credit_note_count come off WKINVREG — the same authority as the money — at document grain.
1. They are null at line grain. A row only carries them when the row IS a document grouping: group_by=none · branch · franchise · advisor. For group_by=job (the default) they are null, because a bucket fans out to dozens of job_code × job_type rows and stamping the bucket's 159 ROs onto 40 of them gives a column that looks right and totals to 6 360. meta.counts tells you which case you are in.
2. ro_count does not sum across buckets. It is a DISTINCT count — an RO invoiced Monday and credited Thursday is distinct in both buckets, so adding buckets counts it twice. invoice_count and credit_note_count do sum. For a window figure read summary.totals, which runs its own un-grouped query for exactly this reason. Since 2026-08-18 that window total counts an RO as (branch, ro_number), matching ?job_age=1; it used to count distinct ro_number alone, which is the same figure only until a dealer reuses a number across branches. The per-row ro_count is still on the number alone.
Related: doc_count has always counted documents (invoices + credits), not repair orders — on 2026-08-03 one dealer shows 168 documents against 159 distinct ROs. Anything reading doc_count as "repair orders" has been wrong by that margin.
A document_no or ro_number lookup searches all history. Both are self-restricting and more selective than any date range, so passing either drops the default date window rather than silently clipping the result to the current year — meta.date_from/date_to come back null with meta.date_window: "unbounded …". An explicit date_from/date_to still wins, which is how you scope a prefix search like ?document_no=WI001* to one year. Both columns are indexed, so unbounded here is a seek, not a scan.
Credit notes are included by default, so the figure is net; ?credits=0 excludes them. Values are read as stored (native sign, no forced negation) — the same rule invoices.php follows. Because that sign convention is a DMS behaviour rather than a schema guarantee, summary.by_credit_flag always breaks out I vs C so it is readable at a glance. A credit's job lines are filed under the original invoice's document_no — wkinvreg carries two rows with two numbers, the za_* mirrors carry one number for both — so the credit is linked to its lines through invoice.orig_invoice_no, keyed (document_no, module_type='W', invo_type). Where that is null it falls back to the nearest inv_rev_date match. Because the two sides are two different document numbers, a plain ?document_no= search shows one of them and a total that reads as if the work was never reversed — pass ?include_related=1 to get the whole family back, netted, however many months apart they were raised. That same orig_invoice_no link is what carries ?people=1's authorised by across: the DMS stamps reversal_auth_id on the invoice that was reversed, and the reader wants it against the credit that reversed it.
KeyTypeRequiredDescription
date_fromdateoptYYYY-MM-DD on work_date (the invoiced date), inclusive. Default: 1 January of the current year — unless document_no / ro_number is passed, which drops the default window
date_todateoptYYYY-MM-DD, inclusive of the whole day. Default: today (same exception)
bucketstringoptday · week · month · year (default month). week = week within the month, 1–5. year is the FINANCIAL year, not the calendar one (changed 2026-08-15) — it joins SYSCALENDAR, the same per-dealer financial calendar accounting_periods.php reads, so a dealer running Apr–Mar buckets Apr–Mar. period_key is the bare FY number and period_label reads FY2027. NB the cost of a call tracks the window, not the bucket: a multi-year window is a multi-year query (~9 s per financial year measured), so bucket=year over several years is slow regardless of how few rows it returns
group_bystringoptjob (default: job_code × job_type) · job_type · branch · franchise · advisor · document · none. document = one row per WKINVREG document (document_no + invoice_credit, the PK), carrying ro_number / branch / franchise / sales_advisor / work_date alongside the money — the drill leaf behind Pulse Branch Summary and Top Advisors. A credit note is its own row (invoice_credit=C, negative), so a reversal is visible instead of netted away. Being a document grouping it inherits the reconciliation, so it TIES to the same figures group_by=branch/advisor report on the same filters — including under a job_type slice, where the residual is suppressed by design. A document with an incomplete snapshot still comes back as TWO rows (allocated + residual with allocated:false); sum them per document. Since 2026-08-21 the residual reports a REAL GP with cost 0 instead of total_gp:null — a cost the DMS does not record is treated as ZERO, the same rule that already governed other_sales, so total_gp now equals total_sales minus the four costs exactly. It was R4 007 on one dealer, one row of 2 545 documents (0.013% of sales); allocated:false still marks the row, so a caller wanting to exclude GP with no measured cost behind it can still find it. The same ruling removed the gross_profit:null that ?technician=1 returned for a technician with no cost rate — that now reads as a 100% margin, R275 939 or 3.46% of invoiced labour on the dealer measured, so a 100% margin there means "this dealer has not set a cost rate for this technician", not "this work was pure profit". ?hours=0 is unchanged and still withholds the number: there the cost query was never run at all, so there is no measured zero to report. Since 2026-08-18 each document row also carries equipment and franchise_name — the machine (WKROFILE stock_no → VHSTOCK.model_name, else WKVEHFL model_name / make+model, the SAME expression workshop_wip.php uses so the two tabs cannot disagree about what a machine is called) and the franchise's CODTYP description. Both are null when the RO has no WKROFILE header or the vehicle file does not name the machine — emitted rather than omitted, so a caller can tell "no equipment on this RO" from "this endpoint does not send equipment". They come from a SEPARATE lookup keyed on (branch, ro_number) run after the rows are composed, NOT from the group-by keys: those are all WKINVREG columns and that is exactly why the residual is exact, so a joined column had no business in there. Costs one extra indexed query on group_by=document only — WKROFILE (branch, ro_number), WKVEHFL (reg), VHSTOCK (no), all verified present on the dealer's ensure_indexes dry-run. job_type = one row per job type per bucket — the same dimension job carries, minus the job_code fan-out (463 rows → ~56 for one window, measured). LINE grain like job: counts come back NULL and the residual is matched on the bucket, so it still sums to the window total with an Unallocated row. Drill one type with group_by=document&job_type=Retail — but a job slice suppresses the residual by design, so that leaf covers the allocated lines only and does not re-add Unallocated
branchstringoptComma list of invoice branches to include
branch_includestringoptAlias of branch (Pulse multi-select CSV)
branch_excludestringoptComma list of branches to exclude
franchisestringoptComma list of franchise codes to include
franchise_includestringoptAlias of franchise (Pulse multi-select CSV)
job_codestringoptFriendly job-code bucket(s): Sublet / Repair / Service / raw code (comma list). Suppresses the Unallocated row
job_code_includestringoptAlias of job_code (Pulse multi-select CSV)
job_typestringoptFriendly job-type(s): Retail / Fleet / Internal / Warranty / Policy / Sundry / Excess / Project Billing
job_type_includestringoptAlias of job_type (Pulse multi-select CSV)
trade_typestringoptComma list of WKINVREG.trade_type codes
sales_advisorstringoptComma list of advisor codes (WKINVREG.sales_advisor)
ro_numberintegeroptA single repair order — the lookup form, which drops the default date window (see document_no). To filter inside a period use ro_number_include instead; the two are not interchangeable, and a comma list passed here is silently ignored (it fails is_numeric), returning the whole window while looking filtered
ro_number_includestringoptRepair orders to keep, inside the window — comma list, exact values only. This is the filter form: it never touches the date range. No * here on purpose — the wildcard belongs to filter_search, which is what fills the picker, and what comes back from it are exact picks. Keeps the clause an integer IN on a numeric column rather than a cast-and-LIKE
document_nostringoptInvoice / credit-note number. Comma list; a trailing * on a term makes that term a prefix search (WI001*). Applied at document level, so the buckets returned are that document's alone. Drops the default date window — searches all history. Pass date_from/date_to explicitly and you get the intersection instead, which is how it also works as an in-period filter
filter_searchstringoptPrefix search for the Filters panel's two high-cardinality pickers. One of document_no or ro_number (whitelisted — anything else is a 400), paired with q. Returns {data:[{<field>: value}, …]}, TOP 100, keyed by the field name so the shared filters engine consumes the rows directly. Reads WKINVREG at document grain — no job-line join, no clocking leg, no cost legs
qstringoptThe prefix for filter_search. 2 characters minimum (matching the shared engine's own floor); shorter returns an empty data rather than every document in the window
filter_facetsintegeropt1 ⇒ the three low-cardinality document-grain pickers in one round trip, and nothing else: {branches:[code,…], franchises:[code,…], sales_advisors:[{value:code, label:name},…]}. Branch and franchise are bare codes — the client already holds both name maps (branchName, franchises.php). sales_advisor cannot be: WKINVREG.sales_advisor holds a contact code (live values look like 9025295) and ?sales_advisor= matches that code, so the picker submits codes and displays names, resolved off ADMINISTRATOR.CONTACT exactly as workshop_wip.php does. The code stands in as its own label when the join misses — that advisor still has invoices. Like filter_search, this inherits the call's window and branch scope, so a branch-limited user is only ever offered values they can also retrieve
include_relatedintegeroptWith document_no: also return the rest of that document's family — the original invoice and every credit raised against it, resolved via invoice.orig_invoice_no, across months. Ignored without document_no; ro_number never needs it
peopleintegeroptWith group_by=document: add who to every row. sales_advisor_name resolves the sales_advisor code the row already carries. actioned_by / actioned_by_name is invoice.lop_id — who last wrote the document; never null on this DMS, and a different person from the advisor on 27% of documents (10 127 of 37 438, Jan–Aug 2026), so it is not a copy of the advisor column. authorised_by / authorised_by_name is wkinvreg.reversal_auth_id — who signed off the reversal; the DMS stamps it on the original invoice, so it is emitted against the credit that reverses it (resolved through invoice.orig_invoice_no) and is null on every non-credit row. A code CONTACT has no row for stands in as its own label, so nobody's work disappears; null means nobody. Opt-in, because it costs three IN-list probes that scale with the document count — three rows on an ro_number lookup, ~37k on a year. Ignored for every other group_by
creditsintegeropt0 excludes credit notes; default 1 (net of credits)
countsintegeropt0 skips the two document-count aggregates — ro_count / invoice_count / credit_note_count return null everywhere. Default 1. Also how you measure what they cost: run ?debug=1 with and without
hoursintegeropt0 skips the ZA_WKMECHWK clocking leg — invoice_hrs / hours_work and now labour_cost return 0, because cost rides the same aggregate — and labour_gp returns null, deliberately not a number: 0 cost would report a 100% margin on a call that merely declined to measure it. The flag gates the table, not just hours. other_cost / other_gp are unaffected — Other has no cost leg to skip, so nothing there goes unmeasured. Default 1. That table is the largest in the query, so a money-only caller should not pay to aggregate it
job_topintegeroptKeep the N highest-value job codes; roll the rest into one Other row per period and job type. Ranked by absolute total_sales, deliberately not by name — a dealer can have ~480 distinct codes of which most are machine ids or model-specific (8R_1500Hrs), and that vocabulary differs per customer, so a name map would be config nobody maintains. Unallocated is exempt. Opt-in
min_total_salesnumberoptOnly rows with total_sales ≥ this
job_ageintegeropt1 ⇒ TURNAROUND at repair-order grain, in its own response and nothing else: {bands:[{band,label,from_days,to_days,ro_count,total_sales,avg_age_days,age_min,age_max},…], summary:{ro_count,total_sales,aged_ro_count,avg_age_days,unknown_ro_count,unknown_sales,…}}. Days from WKROFILE.creation_date to the RO's LAST INVOICE — max(work_date) where invoice_credit <> C, not its last document, because a credit note is not a delivery date (corrected 2026-08-17: it had been using the last document, so an RO invoiced on the 3rd and part-credited on the 20th was aged to the 20th). An RO invoiced, credited and re-invoiced took as long as its final invoice, which is when the customer actually got the vehicle back. The bands depend on ?age_bands= (the customer's vertical, below): motor = Same day · 1–2 · 3–5 · 6–10 · 11–30 · 31+ · Unknown, agricultural = 0–7 · 8–30 · 31–90 · 90+ · Unknown. Ignores bucket entirely — age is a property of the RO, not a time bucket — and short-circuits above the financial-year block, so it never pays for it.
NOTHING IS EXCLUDED (2026-08-17, second correction). ro_count / total_sales are every RO in the window and every rand of it, and both tie to summary.totals on the same filters — so this card can sit next to the period grid and the two agree. There is no value rule of any kind. It used to band on what an RO was WORTH, pulling net-zero work out as “not sold” and net-negative work out as a “prior-period reversal” into bands 7 and 8, which then had to be reconciled underneath the chart; value is simply not what a turnaround card measures. So nil-value work (internal / warranty billed at zero) bands on its dates and contributes R0, and a credit-only RO is aged to the invoice its credit reverses — that invoice sits in an earlier period, and its date is when the customer got the vehicle back. Measured: RO 557531 invoiced 23 Jul, credited 4 Aug, opened 27 May → 57 days, band 31+, carrying its −R3 250. A band total can therefore be lower than the sum of its invoices — real money in the right bucket. An RO can never net negative over its life: a reversal nets to zero until you re-invoice. not_sold_* / reversal_* / excluded_* / all_* are gone, not zeroed.
Unknown = no WKROFILE header for that RO (so no creation date), or no invoice anywhere to age to. Reported rather than dropped by an inner join, for the same reason the Unallocated row exists, and left out of avg_age_days — an undated RO is not a same-day job.
job_code / job_type do not apply (they slice job lines; this never opens the snapshot) and meta.job_slice_ignored reports when one was sent. Every other filter does apply. Still the cheapest aggregate in the endpoint: WKINVREG + WKROFILE only, driven by AITONE_WKINVREG_WORKDATE and probing AITONE_WKROFILE_BR_RO. The credit lookup adds one more leg over the window's credit notes only — a few hundred rows — each probing AITONE_WKINVREG_BR_RO (branch, ro_number, work_date), which covers the seek, the <= range and the max(). All three indexes were verified live on the dealer's own ensure_indexes dry-run before the SQL was written
age_bandsstringoptWith ?job_age=1 — which turnaround bucket set: motor (default) = Same day · 1–2 · 3–5 · 6–10 · 11–30 · 31+ · agricultural = 0–7 · 8–30 · 31–90 · 90+. Both end with Unknown, which is always band 6 in either set (a fixed wire contract — so bands 4 and 5 are simply unused on the agricultural side rather than renumbered). Added 2026-08-17: an agricultural machine legitimately sits for weeks (parts on backorder, seasonal downtime), so day-wide buckets are the useful shape and the tight ones pile everything into 31+; a motor job that takes a week is already late, so the reverse holds. Same measure, different scale of normal.
This endpoint cannot resolve it itself — it holds a dealer api_key and nothing more, while customer_type lives in the Gauge platform's PostgreSQL (gauge.companies) on the other host. Gauge's bi_dashboard/api/proxy.php reads it off the SESSION's company (falling back to the bound dealer's company, which is what a share-view session has) and overwrites whatever the browser sent, so a client can never relabel a server aggregate by editing a URL. Echoed back as meta.age_bands. The band boundaries and the SQL CASE are generated from ONE table (salesAgeBands()), so from_days/to_days can never disagree with the bucketing
technicianintegeropt1 ⇒ INVOICED LABOUR PER TECHNICIAN, own response: {data:[{mechanic_code,mech_name,lines,ro_count,hours_work,invoice_hrs,sell_val,cost_val,gross_profit,efficiency,sell_rate,cost_rate,reversal_lines, ro_count}], summary:{…}}. The Sales twin of WIP's Technician Summary. Nothing is apportioned — ZA_WKMECHWK carries sell_val AND cost_val per CLOCKING LINE, so the DMS has already valued each technician's own work. Reads the SNAPSHOT, not live WKMECHWK, because live is keyed (ro_branch, ro_number, seq) with no document_no: joining it to WKINVREG on branch + RO alone would match every clocking line to every document on that RO and multiply hours and value on a multi-invoice RO. BASIS: clocking on documents INVOICED IN THE WINDOW, not hours clocked in the window — a tech who clocked in July on an RO invoiced in August belongs to August. Right for a Sales tab, and the OPPOSITE of WIP's "open right now". efficiency / sell_rate / cost_rate are null, not 0, on a zero divisor: no booked hours is not 0% efficient. reversal_lines counts reversal_ind='Y' rows without altering totals, which sum AS STORED. Short-circuits above the financial-year block and honours every filter salesDocWhere() speaks. FIXED 2026-08-18: the set is bounded by salesHoursSource() — the same DISTINCT driver the labour aggregate uses, on (branch, ro_number, SNAPSHOT document_no, ro_type). It previously hand-rolled join WKINVREG on branch + ro_number + document_no, and that was wrong in a way no single number showed: ZA_WKMECHWK files both the Invoice and the Reversal rows under the original invoice's document_no (separated by ro_type) while WKINVREG gives the credit note its own number, so a raw join attached every reversal to the invoice's row. Measured on Aug 2026: ?credits=0 and ?credits=1 returned the same total to the cent — a feed that will not move when the credits flag moves is the tell — and it carried an arbitrary subset of reversals (those whose original invoice fell inside the window), reading +5.81% against Revenue Composition's Labour. Once the scope matches, sell_val and WKINVREG.labour_charged agree to ~0.2%, inside the ~1.1% summary.reconciliation.labour_cost_check already reports: it was a join bug, not a valuation one. Added 2026-08-18
mechanic_codestringoptWith ?technician=1 only — ONE ROW PER DOCUMENT for that technician instead of the roster: {data:[{document_no,ro_type,credit_no,credit_notes,ro_number,branch,work_date,lines,hours_work,invoice_hrs,sell_val,cost_val,gross_profit,efficiency}], summary:{…}}. The drill leaf under Pulse's Technician Summary. It exists because the shared group_by=document leaf knows a document's TOTAL and this has to show how much of it was THIS technician's time. Built from the SAME driver as the roster, grouped one level finer, so summing the leaf gives the technician's own row by construction. GRAIN IS THE SNAPSHOT DOCUMENT + ro_type (meta.grain says so), not one row per document number: a reversal is filed under the invoice's number, so an invoice and its reversal are two rows sharing one document_no and ro_type (Invoice | Reversal) is what separates them. ro_type replaced invoice_credit here on 2026-08-18: the old flag came off a WKINVREG row these clocking lines do not belong to, so the Pulse badge could read "Invoice" on a reversal. work_date is the clocking row's own inv_rev_date, so a Reversal row is dated when it was reversed even though it carries the invoice's number. credit_no is the CREDIT NOTE's own number on a Reversal row (null on an Invoice row): ZA_WKMECHWK cannot supply it — filing reversals under the original is the whole point of it — so it is resolved from INVOICE where invo_type='C' AND module_type='W' AND orig_invoice_no = the snapshot number, as a scalar subquery over a wrapping select. Not a join and not a driver column: either would fan out the clocking rows of an invoice carrying two credits and silently double that row's hours and value. Gated on ro_type='Reversal' so Invoice rows never probe, and it rides AITONE_INVOICE_ORIG (orig_invoice_no, module_type, document_no) — verified live — so it is an index seek reading document_no out of the index. credit_notes counts the candidates: credit_no is max(), which is a deterministic PICK (highest number = latest credit) and not proof of uniqueness, so credit_notes > 1 says the number shown is one of several. document_no is still the snapshot number and is not overwritten — it is what the clocking is filed under and what ties the pair together, so the response states both and the client chooses; Pulse shows the credit note and names the invoice on hover, falling back to document_no where credit_no is null. Added 2026-08-18 mechanic_code is compared WITHOUT trim(): it is char(15) and padded, but a char comparison ignores trailing blanks, so the predicate stays sargable against the mechanic_code index. Read with isset() + length, not truthiness — 0 is a legitimate code. Added 2026-08-18
mechanic_code_includestringoptComma list of technician / mechanic codes. A SEMI-JOIN, not a column — mechanic_code lives on the clocking table, so this asks "did this technician clock anything on this document" and leaves the document's VALUE whole. Same rule workshop_wip.php states for its Technician field: it selects whole ROs without slicing their value. That is the honest reading for the document leaf too — a document's invoiced total is not divisible by who turned the spanner, so a leaf opened from a technician row lists that technician's documents at full value rather than an apportioned slice. EXISTS rather than a join so two clocking lines cannot duplicate a document; driven by AITONE_ZA_WKMECHWK_DOC_JOB. Added 2026-08-18 for the Sales Technician Summary drill
risk_rollupflagopt1 ⇒ the Workshop Risk tab's SALES clocking exception and nothing else: {meta:{mode,grain,rows}, lines:[{mechanic_code,mech_name,document_no,ro_type,ro_number,branch,work_date,lines,hours_work,invoice_hrs,sell_val,cost_val,gross_profit,efficiency,rule_nocost}]} — ONLY the (technician × snapshot document) rows that trip the rule, so this is KBs rather than the whole roster. The rule is cost_val = 0 against sell_val > 0: a technician with no cost rate set up in the DMS, which since 2026-08-21 reads as a confident 100% margin rather than cost n/a. Built from the SAME driver as ?technician=1&mechanic_code= grouped one level wider, so a flagged row is exactly the row that technician's own drill leaf would show and the two cannot disagree. It also feeds the flames on Pulse's Sales Technician Summary, which fetches this rather than re-deriving the rule locally — that card's feed is rolled up per technician while the rule lives per document, so a local test would flame an arbitrary subset and read as complete. Period-scoped like every Sales card; the test is absolute and per document, so a wider window cannot accumulate a false positive. THERE WERE TWO RULES UNTIL 2026-08-24: rule_hours (ABS(invoice_hrs) > 500 — a mis-allocation between two technicians on one invoice, where the NET was correct and nothing else would ever flag it) was removed together with its register exception as a wasted slot, and meta.thresholds went with it. Added 2026-08-21; documented here 2026-08-24
reversal_pendingflagopt1 ⇒ invoices authorised for reversal that were never reversed, and nothing else: {meta:{mode,grain,rows,value_pending,gp_pending,gp_unknown_rows,age_basis,age_basis_note,unlinked_credits,credits_in_window}, rows:[{branch,ro_number,franchise,sales_advisor,sales_advisor_name,document_no,work_date,invoice_val,invoice_gp,authorised_by,authorised_by_name}]}. Both contact codes resolve through one batched CONTACT lookup, and each stands in as its own label where CONTACT has no row. One row per INVOICE, not per RO — an RO can carry two authorised invoices and they are two findings. The rule has two halves: wkinvreg.reversal_auth_id is set (somebody approved cancelling it) AND no workshop credit names that document in invoice.orig_invoice_no. The field is not cleared when a reversal happens — verified on RO 547764/branch 62, where invoice 2934069 still carries its authoriser and credit 2934245 exists — so the anti-join is the only thing separating pending from done. Linked through orig_invoice_no and not at RO grain: an RO routinely carries several invoices, so "no credit anywhere on this RO" would let one credit clear all of them. Invoices under ?value_floor= (default R100, echoed as meta.value_floor) are ignored — measured Jan–Aug 2026, six of thirty rows fell under R100 and one was exactly R0, which is a keying artefact rather than money at risk. NOTHING IS AGED. There is no authorisation date in the DMS, work_date is the invoice date, and an old invoice can be approved today — so meta.age_basis is null and age_basis_note says why. Do not age these rows. gp_pending is null when no row in scope had a cost record, and invoice_gp is null per row on the same rule — unmeasured cost is not zero profit. unlinked_credits is the feed reporting its own blind spot: window credits whose orig_invoice_no is NULL name no original, so each one could leave a genuinely reversed invoice looking pending. It is the CEILING on false positives, not a count of them — 0 means the rule is exact on that data. Both halves seek, checked against a live dealer_ensure_indexes dry run rather than assumed: AITONE_WKINVREG_WORKDATE on the driver, AITONE_INVOICE_ORIG index-only on the anti-join. Ordered by exposure, biggest first. Feeds the Workshop Risk tab's "Authorised for reversal, not reversed" exception — a widget-less item, since reversal_auth_id appears on no card. Added 2026-08-28
reinvoice_varianceflagopt1 ⇒ the Workshop Risk tab's RE-INVOICE exception and nothing else: {meta:{mode,grain,variance_floor,rows}, rows:[{ro_number,branch,franchise,sales_advisor,credit_no,credit_date,reversed_val,reversed_gp,reinvoice_no,reinvoice_date,reinvoice_val,reinvoice_gp,variance,gp_variance}]} — one row per repair order RE-INVOICED inside the window at a value that differs from the invoice that was reversed. Partial credits do not exist on the DMS, which is what makes this computable without resolving orig_invoice_no: a reversal always cancels its invoice exactly, so abs(credit) is the reversed invoice's value and the re-invoice is the only leg that can carry a different number. When the original invoice and the reversal happened is NOT tested — only the re-invoice is window-bound. An invoice reversed and re-invoiced at the SAME value across a period boundary is accounting timing and nothing more (measured on RO 562543: R550 361 out, R550 361 back). The sequence key is document_no, not work_date: all three legs of a correction routinely share a date (RO 562315 — invoice, credit and re-invoice on one day), while document_no is the PK, monotonic as issued and indexed. One row per CORRECTION, not per RO — the credit is the last one before the re-invoice, and the re-invoice the first invoice after that credit. Nothing restricts an RO to its latest credit, so an RO corrected twice returns TWO rows: July 2026 is 64 rows across 62 distinct ROs (RO 540351 reports +2 021 then −2 894). Kept that way deliberately — both movements are real and collapsing hides one — so a caller counts "64 re-invoices across 62 repair orders", never "64 repair orders". FIRST rather than last matters: RO 558653 runs 50 885 invoiced → reversed → 49 146 re-invoiced → a separate 1 739 invoice, and taking the last would report −49 146 instead of −1 739. Known limit — split re-invoicing: that RO's two invoices total the original exactly (49 146 + 1 739 = 50 885), so its true variance is arguably zero while this reports −1 739; the rule pairs one credit with one invoice and so UNDERSTATES where a re-invoice is spread across several. Measured July 2026: 122 ROs re-invoiced, 55 at the same value, 61 flagged at the default floor across 22 branches — net −R350 550, gross R450 212, worst RO 556509 re-invoiced at R0 against R72 265 reversed. GROSS PROFIT ARRIVED 2026-08-27, and this entry said the opposite. It claimed GP "means the lines join and a materially dearer query" — true of the tab's MAIN aggregate, which needs cost for every document in the window, and never true of this one, where the documents are already known (two per correction, ~120 a month). The card shipped with abs(variance) labelled "gross exposure" instead, which Ernst read as a gross-profit column and rejected: "the gross exposure cannot be positive value on all the reversals" — a sum of magnitudes has no direction. gp_variance is reinvoice_gp − reversed_gp, negative when the correction cost profit. Sales variance and GP variance are different findings: an RO re-invoiced for LESS can carry MORE gross profit (work descoped, cost fell further than price), and one re-invoiced for the same money can carry less. Cost comes from invoice — all three elements, labour included — which is NOT what the main aggregate does (there labour_cost is za_wkmechwk.cost_val, verified to the rand). Two reasons, both overrulable: za_wkmechwk keys on the SNAPSHOT document + ro_type, so a credit's labour cost sits under a document this query has not resolved; and only the DIFFERENCE is reported, so a systematic bias in labour_cost_val affects both legs and largely cancels. gp_variance is NULL, never 0, when either leg has no cost record — a correction whose cost was never recorded has no honest GP movement, and 0 would claim the profit did not move. COST ARRIVES IN A SECOND QUERY, NOT A JOIN — and that took four attempts. Three ways of joining a grouped invoice aggregate into the pairing query were tried and all three were slow: unbounded, bounded on ro_number (correct but a full scan, since neither curated index — AITONE_INVOICE_DOC_MOD_TYPE, AITONE_INVOICE_ORIG — leads with that column), and bounded per leg on document sets. Measured live: 10 rows in 307 ms against 57 rows in 4 455 ms — super-linear, so not a seek. The pairing query is fast precisely BECAUSE it only touches wkinvreg. So it stays that way: it returns the ~114 document numbers it needs, and a second tiny query probes invoice for exactly those (indexed on document_no, chunked at 500, integers inlined per the ODBC substitution footgun), with the GP composed in PHP. Same shape as the equipment lookup behind ?group_by=document. Two things had to be true for that to be fast, and the first attempt got the second wrong: invoice.document_no is a CHAR column, so the list must be QUOTED — inlining bare integers forces an implicit conversion, which is not sargable, and it measured 12 800 ms on one month with the index ignored. Quoting is also a correctness fix: document numbers may be alphanumeric (see ?document_no=) and CHAR values can carry leading zeros, so an (int) round-trip would match nothing at all on such a dealer while looking healthy on this one. One measurement that shaped it and still applies: 11% of August corrections and 5% of July ones have a credit dated OUTSIDE the window (earliest 2026-03-25 against an August window), which is why the cost probe is keyed on the documents the pairing NAMES rather than on anything derived from the window. Added 2026-08-26, GP 2026-08-27
variance_floornumberoptWith ?reinvoice_variance=1 — the minimum abs(variance) to report. Default 100. Not decoration: of the 67 July 2026 ROs whose re-invoice moved, six moved by under R100 (−93, 63, −24, −23, 1, −1) — cents-level rounding on a re-key, which would have taken six of the register's five-row pages. Echoed back as meta.variance_floor so the Risk tab's banner can state the floor that was actually applied rather than repeat a constant that might drift from it — the lesson meta.thresholds taught on 2026-08-24. Added 2026-08-26
bandintegeroptWith ?job_age=1 only — the repair orders IN one band instead of the bands: {data:[{ro_number,branch,franchise,sales_advisor,created_date,invoiced_date,age_days,total_sales,doc_count},…], summary:{band,band_label,ro_count,total_sales,avg_age_days,…}}. The drill leaf behind the Pulse Job Age card. Bands are 0=Same day · 1=1–2 · 2=3–5 · 3=6–10 · 4=11–30 · 5=31+ · 6=Unknown. That is the whole vocabulary — 7 (not sold) and 8 (prior-period reversal) existed for one day on 2026-08-17 and are gone, both classes being banded on their age now, so a stale link carrying either returns an empty, unlabelled leaf. Band 0 is real, so this is read with isset(), not truthiness. Built from the same levels as the band aggregate with the band CASE moved from GROUP BY to WHERE (one shared salesAgeBandCase()), so the leaf can never show a different population from the band it was opened from — ro_count / total_sales here equal that band own figures. RO grain, and no GP: an age belongs to the repair order (an RO invoiced twice would carry two ages), and cost would need the invoice + clocking joins to show a column the Job Age card does not have — it measures turnaround and value, never margin. doc_count marks a multi-invoice RO; franchise / sales_advisor are max() over the RO documents. invoiced_date is the date the age ran TO, so on a credit-only RO it is the reversed invoice's date and falls outside the window — that is the point, not a bug. Same filter vocabulary and same job_slice_ignored caveat as the band aggregate
job_facetsintegeropt1 → {job_codes:[…], job_types:[…]} for the Filters modal, scoped to the same window, and nothing else. Short-circuits before the main aggregate and skips both the hours and the element-cost legs, since it selects two label columns and every aggregate column would be discarded. That makes it the cheap path for filling a picker — a 31-day window measured 930–2329 ms while it was still paying for the cost legs (2026-08-14/15), against tens of ms without them.
page / limitintegeroptlimit blank = all rows; number = that many (else 20)
debugintegeroptExactly 1 appends a _debug block: wall_ms, query_ms_total and per-query ms / rows / bytes / generated SQL. Comparing wall_ms against query_ms_total separates database time from PHP time
json
{ "meta": { "total": 54, "page": 1, "limit": "all", "total_pages": 1,
    "bucket": "month", "group_by": "job",
    "date_from": "2026-01-01", "date_to": "2026-08-07", "credits": "included" },
  "summary": {
    "row_count": 54, "document_count": 3184,
    "totals": { "labour_sales": 4820150.00, "parts_sales": 3110480.00,
      "other_sales": 204300.00, "sublet_sales": 388920.00,
      "total_sales": 8523850.00, "invoice_hrs": 28714.5000, "hours_work": 30188.2500,
      // window-level, from their own un-grouped query — ro_count does NOT sum across buckets; an RO here is (branch, ro_number)
      "ro_count": 3021, "invoice_count": 3102, "credit_note_count": 82 },
    "by_period": [{ "period_key": "2026-01", "period_label": "Jan 2026",
      "total_sales": 1042300.00, "invoice_hrs": 3512.7500 }],
    "by_credit_flag": {
      "I": { "doc_count": 3102, "invoice_value": 8801420.00 },
      "C": { "doc_count": 82, "invoice_value": -277570.00 } },
    "reconciliation": { "wkinvreg_total": 8523850.00,
      "allocated_total": 8498120.00, "unallocated_total": 25730.00,
      "ties_to_wkinvreg": true } },
  "data": [{ "period_key": "2026-01", "period_label": "Jan 2026",
    "year": 2026, "month": 1, "day": null, "week_of_month": null,
    "job_code": "Service", "job_type": "Retail",
    "labour_sales": 312400.00, "parts_sales": 198220.00,
    "other_sales": 12100.00, "sublet_sales": 21450.00,
    "total_sales": 544170.00,
    "invoice_hrs": 1842.5000, "hours_work": 1930.7500,
    // labour_cost = ZA_WKMECHWK.cost_val; labour_gp = labour_sales - labour_cost. Both null/0 under ?hours=0
    "labour_cost": 96420.00, "labour_gp": 215980.00,
    // Other has NO cost of sales, so other_cost is always 0 and other_gp always == other_sales. NOT gated by ?hours=0
    "other_cost": 0.00, "other_gp": 12100.00,
    // sublet_cost = PURCHASE_CONTROL.order_value; sublet_gp = sublet_sales - sublet_cost. Both null/0 under ?sublet=0
        "parts_cost": 158420.00, "parts_gp": 39800.00,
    "sublet_cost": 18230.00, "sublet_gp": 3220.00, "total_gp": 271100.00,
    "doc_count": 418,
    // null here because group_by=job is LINE grain — see the note above
    "ro_count": null, "invoice_count": null, "credit_note_count": null,
    "allocated": true }] }

Service Bookings

Reads WKROFILE — one row per open repair order with its booking slot, customer and contact details, vehicle, work and advisor. The feed behind a booking board, an SMS/email reminder run, or a booking table you keep in step with the DMS. No financial columns: for the value of a job, use Work In Progress.

planned_technician_code rides on every row, and it is the one field here nobody has confirmed exists. It answers “who is this job for” — the call a foreman makes at 07:30 for work nobody has touched — probed from mechanic_code / tech_code / technician_code / allocated_tech, null when nothing binds. Every other technician link in this API is retrospective: they read WKMECHWK, which is clocking, so a car booked for tomorrow belongs to nobody. Check filters.bound_columns.technician — null there means no such column on this install, a different answer from a bound column that is simply always empty. Raw and unresolved, no name attached, until somebody confirms what the column holds.

This one also writes. POST to the same file moves one RO's progress status — the board column it sits in — and GET ?stages=1 is the picker of valid stage codes to move it between. POST ?update_req_datetime=1 is the second write: it moves a booking to another day or slot, setting both of WKROFILE's booked-day columns because this API is split down the middle over which one it reads. One column, one RO, and it stamps the same last-modified watermark the sync half reads, so the move reaches every other consumer of this feed instead of being visible only to the app that made it. The write needs the WRITEKEY header; the read half is unchanged.

confirmed_status rides on every row (new 2026-09-25). WKROFILE.Confirmed_Status, trimmed and uppercased: Y, N, or blank — which is not N. Blank means nobody has been asked; N means somebody was asked and said no, and a consumer that treats the two alike reads a whole install's untouched history as refused. It is here because a workshop app has to know whether a car may be worked on yet, and it is settable from outside the DMS screen by the confirmed write below.

?readiness=1 is the pre-shift check. Booked ROs for the week ahead, each answered yes or no on whether anything we can see is standing in the way of starting it, with a per-date rollup — "tomorrow: 14 jobs, 5 blocked". It is the mirror of ?due_out=1 and reads the same probes, so a job that is waiting for parts on one is waiting for parts on the other. Read is_ready narrowly: customer authority, bay space and technician allocation are recorded nowhere this API reads, and an RO with no parts order at all is either a job needing no parts or a job whose parts nobody has ordered — the DMS does not distinguish them. Both gaps are counted rather than papered over.

?due_out=1 is the other end of the same job. Everything else here is keyed on the booking slot — when the car is meant to arrive. That mode is keyed on the promise out — when we said the customer could collect — and buckets each open RO into overdue · due_today · due_later · unpromised, tagged with why it is still open, and rolled up by branch and advisor. Mind the word "overdue": this endpoint's category already contains one, and it means the car has not arrived. A row can be category: "Due Today" and promise_status: "overdue" at the same time — the car came in this morning against a promise made for yesterday. Both are on every row and they are not supposed to agree.

Keeping a copy in step. Pull once with a date_from and no date_to, then poll for changes. Each response ends with a sync.next object — save it, and send its three fields back as modified_since, cursor_branch and cursor_ro on the next call. Keep calling while sync.more is true, then wait and start again. Don't work out the next watermark yourself: sync.next already accounts for the edge cases that lose rows.

GET service_bookings.php ?modified_since= &branch= &date_from= …

Open means not yet invoiced — the same set the WIP board shows. Default window is today → today + 30 days on the booking slot; date_from and date_to are independent, so setting one leaves the other open-ended. Bookings that close between polls come back with is_open: "N" so you can clear them your side.

?awaiting_collection=1 turns that around. It asks the one question an open board cannot: which job cards are invoiced and have still not been stamped picked up — the cars the workshop is finished with, has billed, and which are nonetheless occupying a bay. It lifts the open gate itself and reaches backward rather than forward, because every uncollected vehicle was booked in the past; the parameter row below says why both of those are not something to arrange by hand. Its general form is ?progress_status_exclude=, which works in every read mode here.

Worth knowing. customer_id is the person, stable across repair orders — use it, not the name, to match a customer. customer_mobile is the mobile number only and is never filled in from a landline, so it is safe to send to; other numbers arrive separately as customer_phone_alt. ro_stage ("Awaiting Parts", "Washing"…) is the one to show on a board — ro_status is a raw single-letter code. The privacy_* flags are on every row; ?consent= filters by them. Run ?schema=1 once to see which optional columns (ETA, odometer reading, customer request) exist on your install.

Six parameters, and none of them tune the prediction. Three weeks, three jobcards, twenty-four months of history and the sanity bounds are constants in the file, not knobs on the URL — a tuning knob on a prediction is an invitation to tune it until it says what you hoped, and nothing on the response would record which settings produced the list somebody acted on. meta.model reports the numbers used, so a saved response still says what produced it.
KeyTypeRequiredDescription
readinessflagopt1 ⇒ can tomorrow's jobs actually start, and nothing else. The pre-shift check: booked ROs over a forward window (default today → today + 6 on the database clock, ?days= to change — a week, not the list's thirty days, because a pre-shift check is about the shift), each with is_ready, the blockers[] standing in the way, and a per-date rollup: "tomorrow: 14 jobs, 5 blocked". The mirror of ?due_out=1 — that one looks backwards from a promise and asks why a job is late, this looks forwards from a slot and asks whether it can begin — and both read the same probes, one definition each, so a job waiting for parts on one is waiting for parts on the other. is_ready is a claim about what we can see, not about the job. Customer authority, bay space and technician allocation are recorded nowhere this API reads. Two silences look exactly like readiness and both are counted rather than hidden: an RO with no parts order at all is either a job needing no parts or a job whose parts nobody ordered (summary.no_parts_order, and no_parts_order is an opt-in blocker), and work_started is expected to be false on a future booking and is never a blocker. Pair it with Capacity & Load?group_by=ro on (branch, ro_no) — that owns the hours, this owns the blockers, and "of Thursday's 41 hours, 12 are on jobs waiting for parts" is one client-side join away.
daysintegeroptreadiness only. Window length from today, 1–180. Default 7. Ignored when date_from / date_to are given, and dropped entirely by a ro_number lookup — which restricts itself, so putting a date window back would hide the very RO you asked for.
blockersstringoptreadiness only. Which signals count against is_ready. Default parts_awaited,parts_not_ordered,sublet_open — the three that describe work physically outstanding. Also available: no_parts_order (off by default because on most shops most jobs legitimately need none, and defaulting it would paint the board red and teach everyone to ignore it) and stage_blocked (does nothing until blocking_stages names something). Every signal is measured and returned in signals[] either way; blockers[] is the subset that counted.
blocking_stagesstringoptreadiness only. Comma list of case-insensitive substrings of the CODTYP 'WC' stage description — e.g. Awaiting,Approval — which raise a stage_blocked signal. No default, deliberately: the progress-status codes and their wording differ per install, and a guessed list would silently block the wrong jobs or none at all. This is where a shop actually records "Awaiting Authority" — run the ordinary list with ?stage= first to see what your descriptions look like.
technician_colstringoptPin a planned-technician column on the RO header (probed as mechanic_code → tech_code → technician_code → allocated_tech). Reported, never a blocker. "Is a technician assigned" is the obvious third readiness check and it is not answerable: every technician-to-job link this API can reach lives on WKMECHWK, which is clocking — it comes into existence when work starts, so on a job booked for tomorrow it is empty by definition and testing it would flag every future booking.
due_outflagopt1 ⇒ the promise, and whether we kept it, and nothing else. Open ROs keyed on the promised-out datetime rather than the booking slot, bucketed into promise_status = overdue · due_today · due_later · unpromised, each tagged with why it is still open, plus summary.by_branch and summary.by_sales_advisor rollups. Read meta.promise_column.coverage_pct first. The candidate list mixes two readings — promised_datetime is unambiguously a promise, while datetime_out / date_out / completion_datetime could be the moment the car actually left. That difference is fatal and invisible: a departure stamp is null on an open RO, so the mode would return almost nothing and read as a perfect record. Every response therefore measures how much of the open board carries a value in the bound column and warns below 20%. ?datetime_out_col= pins the right one after ?schema=1. Honours every branch / franchise / advisor / stage / consent / search filter below.
days_aheadintegeroptdue_out only. Default 0 = everything promised up to the end of today, which is the morning question: overdue plus due today, and nothing that is still somebody else's problem. 3 adds the next three days for a look at the week.
overdue_max_ageintegeroptdue_out only. How far back to reach for missed promises, in days. Default 90; 0 removes the floor. An RO promised out fourteen months ago and never closed is an admin problem, not a phone call, and unbounded it would dominate the list on any messy open board.
reasonsflagoptdue_out only. 0 drops the four "why" probes. On by default: parts_awaited (an INSALPAR line on the RO's part order with ORDER_QTY − SUPPLIED_QTY > 0) · parts_not_ordered (that shortfall with no PO covering it — always a subset of the first) · sublet_open (a PURCHASE_CONTROL line whose STATUS is not 'C') · not_started (no WKMECHWK line at all). Definitions lifted from WIP Parts on Backorder and WIP Sublet so a job that reads as waiting for parts here is on that board too. They are not exhaustive: a job simply running long, or waiting on customer authority, trips none and returns reasons: [] — that means "no reason this endpoint can see", never "no reason", which is why there is no catch-all bucket. Under ?reasons=0 every row carries reasons: null instead, so a consumer cannot mistake "not asked" for "nothing found".
include_unpromisedflagoptdue_out only. 1 puts the open ROs that carry no promised time at all into data as promise_status: "unpromised". Off by default — they cannot breach a promise nobody made — but they are always counted in meta.promise_column, because "we never told the customer when" is a finding of its own.
modified_sincedatetimeoptOnly bookings changed since this time. Send back sync.next.modified_since. Accepts YYYY-MM-DD, 'YYYY-MM-DD HH:MM:SS' or ISO-8601.
cursor_branchstringoptSend back sync.next.cursor_branch.
cursor_rointegeroptSend back sync.next.cursor_ro. Both cursor fields are optional but recommended — they keep paging reliable when many bookings share a timestamp.
lag_secondsintegeroptHow far behind "now" a change poll stops, in seconds. Default 120, which avoids missing bookings still being saved. Only applies with modified_since.
date_fromdateoptYYYY-MM-DD, inclusive. from is an alias. Independent of date_to.
date_todateoptYYYY-MM-DD, inclusive. to is an alias. Set neither bound for today → today + 30 days.
date_basisstringoptslot (default) | created | modified — which column the date range filters on.
branchstringoptComma list. branch_include is the Pulse alias; branch_exclude removes.
franchisestringoptComma list. franchise_include is the alias.
ro_numberintegeroptA single repair order. Searches all history and finds invoiced ROs too.
regstringoptExact registration
customer_idstringoptOne customer's bookings, comma list
sales_advisorstringoptAdvisor contact code(s), comma list
ro_statusstringoptRaw WKROFILE.RO_STATUS code(s), comma list. I = invoiced; an open card on most installs carries an empty string, not a code. Self-restricting, like ro_number: naming a status drops the open gate, and naming I also swaps the forward window for the backward one (days_back, 90). Both are needed or ?ro_status=I comes back empty twice over — once because the gate ro_status <> 'I' cancels it out, and again because an invoiced job was booked in the past while the default window looks forward. Two silent subtractions and a 200 that reads as "this dealership has no invoiced work". include_closed and date_from / date_to still override. Every response carries filters.open_gate naming which parameter lifted the gate, or saying it is still on.
progress_statusstringoptRaw RO_PROGRESS_STATUS code(s)
progress_status_excludestringoptThe exclusion half of progress_status, the way branch_exclude is the exclusion half of branch. Comma list of raw codes, and it applies in every read mode here including ?due_out=1 and ?readiness=1. An RO with no stage stamped is kept, not excluded — "no stage" is not the stage you named, and the obvious SQL (RO_PROGRESS_STATUS <> 'VP') silently drops every one of them, because a comparison against NULL is unknown rather than true. Those are exactly the job cards nobody has touched, which on this column is a large population rather than an edge case.
awaiting_collectionflagopt1 ⇒ finished, billed, and still in the yard: job cards where ro_status = 'I' (invoiced) and the progress status is not a collected code. The inverse of every other read here — the cars the workshop is done with that nobody has driven away — and it could not be asked before, because progress_status is an include list and no arrangement of codes means "anything except VP".
It lifts the open gate itself. This feed's default WHERE carries ro_status <> 'I'; asking for invoiced ROs without lifting it returns an empty list on every install with a 200 and no clue why. include_closed=0 alongside it is a 400 rather than an honoured contradiction, and every row comes back is_open: "N".
It also flips the date window. The default is today → today + 30 and an uncollected car was booked in the past, so with no explicit bound this reaches backward — days_back (90) with no forward ceiling. date_from / date_to still win.
Which code means collected is collected_status (default VP, Vehicle Picked Up) — the same parameter Move a Booking to Another Day refuses on, so the read and the write cannot end up disagreeing about what "gone" means on an install that spells it differently. Cannot be combined with ?due_out=1 or ?readiness=1, which are open-only; that pair is a 400 naming progress_status_exclude as what you probably wanted.
days_backintegeroptawaiting_collection only. How far back to reach on the booking slot, in days. Default 90 — long enough that a car forgotten for a quarter still surfaces, short enough that the query does not walk a decade of invoiced ROs on a dealership that has never used the VP code at all. 0 removes the floor and accepts that cost. Ignored when date_from / date_to are given.
collected_statusstringoptWhich progress code(s) mean the vehicle has left. Default VP — the same code Workshop Summary buckets its day by, and remappable there as status_picked_up. Read and write: it is what awaiting_collection excludes and what the booked-day write refuses to move. If your install spells it differently, change it in both places or one of them will list cars the other knows have gone.
stagestringoptPartial match on the stage description, e.g. Awaiting
stagesflagopt1 = the stage picker, and nothing else: every CODTYP WC code with its description and how many open ROs sit on it right now. The list a board is built from, and the codes the POST half accepts. Listed from CODTYP rather than from the values in use, so a stage nobody is currently on is still a column a card can be dragged to.
categorystringoptQuote | Future Booking | Due Today | Overdue, comma list. Based on the booking slot, so it can differ from the WIP board's category, which also looks at job value.
has_mobileflagopt1 = only bookings with a mobile number — the ones an SMS run can reach
consentstringoptservice | marketing | third_party, comma list. Keeps only customers with those consent flags set.
include_closedflagoptInclude invoiced ROs. Defaults to 1 when modified_since is set, else 0.
sortstringoptslot (default) | modified | created | ro
expandflagopt1 = nest customer{} / vehicle{} / advisor{}. Flat by default.
searchstringoptPartial on reg, RO number, customer name or mobile
schemaflagopt1 = report which optional columns (ETA, odometer, customer request) exist on this install, and nothing else.
pageintegeroptDefault 1
limitintegeroptBlank = all rows (house rule)
json
{ "meta": { "total": 184, "page": 1, "limit": "all" },
  "sync": {
    "basis": "wkrofile.lop_datetime", "mode": "delta",
    "modified_since": "2026-08-20 06:15:00",
    "lag_seconds": 120, "ceiling": "2026-08-20 08:40:03",
    "db_time": "2026-08-20 08:42:03",
    "returned": 100, "matching": 257, "more": true,
    "include_closed": true,
    "next": { "modified_since": "2026-08-20 08:41:12",
              "cursor_branch": "01", "cursor_ro": 10234 } },
  "data": [{
    "branch": "01", "ro_no": 10234, "booking_ref": "01-10234",
    "datetime_in": "2026-08-21 08:00:00", "datetime_out": "2026-08-21 16:00:00",
    "last_modified": "2026-08-20 08:41:12", "age_days": -1,
    "category": "Future Booking",
    "ro_status": "O", "is_open": "Y", "ro_stage": "Awaiting Parts",
    "confirmed_status": "Y",
    "customer_id": 40118, "customer_name": "Thandi Mokoena",
    "customer_mobile": "0821234567", "customer_email": "t.mokoena@example.co.za",
    "customer_phone_alt": "0113334444",
    "privacy_service": "Y", "privacy_marketing": "N",
    "reg_no": "ABC123GP", "vehicle_id": "AHTKB3CD100123456",
    "vin": "AHTKB3CD100123456", "make": "TOYOTA", "model": "HILUX",
    "equipment": "HILUX 2.8 GD-6 RAIDER",
    "odometer": 58420, "odometer_source": "vehicle_file",
    "odometer_date": "2026-02-14", "next_service_date": "2026-08-21",
    "work_description": "60,000 KM MAJOR SERVICE,BRAKE PADS FRONT",
    "service_type": "Shop",
    "sales_advisor": "Jane Smith", "sales_advisor_mobile": "0837654321",
    "sales_advisor_email": "jane@dealer.co.za",
    "franchise": "TY", "franchise_name": "Toyota" }] }
POST service_bookings.php ?dry_run=1 &allow_closed=1 &stamp=0 …

Moves one repair order's progress status — the column a board column is. ro_stage is described above as the field that maps to a board column, and until now an app could render that board and not move a card on it: a technician finishing a job, a washer taking a car, an advisor putting one on hold were all walks to a DMS terminal. Key it by (branch, ro_number) and send a CODTYP WC code; GET ?stages=1 is the picker that lists them.

One column, and only that column. Not the appointment slot, not the customer, not the vehicle, and not RO_STATUS — invoicing is a DMS act with a ledger behind it, and nothing here should be able to open or close a repair order. One RO per request, and an UPDATE that will not create one it cannot find (unknown key → 404). Any other field in the body is a 400 rather than a silent drop: a client that sent reg expecting it to save must find that out now, not from a board that never changes.

It stamps the sync watermark, and that is the whole point. Every ?modified_since= consumer of this feed learns that an RO changed because WKROFILE's last-modified column moved — the DMS stamps it on every write of its own. A write from here that did not would change the stage on the row and leave the watermark where it was, so the row never comes back in a delta and every other board, reminder engine and cached copy keeps showing the old stage indefinitely, with nothing anywhere reporting a fault. Perfect from the client that made it, invisible to everybody else. So it stamps whichever column the probe bound (filters.bound_columns.modified, normally LOP_DATETIME) to CURRENT TIMESTAMP — and on an install where none bound it refuses, unless you send ?stamp=0 and have therefore been told the consequence. LOP_ID beside it is not written: this API has no operator identity to put there, so an RO touched here keeps the ID of the last human who edited it in Auto-IT while the timestamp moves.

A no-op is not a write. Setting the stage to the one the RO already holds returns changed: false with nothing executed — no UPDATE and, above all, no stamp. A board that re-sends the current stage on every render would otherwise push that RO into every consumer's next delta every time, and a sync that re-delivers unchanged rows forever is indistinguishable from one that is working.

The code has to be a real stage. An unknown code writes to the column perfectly happily and then renders as a blank stage everywhere, because the LEFT JOIN to CODTYP finds no description — which reads as "no stage set" rather than "somebody sent 99". So it is checked against CODTYP WC and a bad one is a 400 carrying the valid list. ?allow_unknown_status=1 overrides for an install whose CODTYP is incomplete. An invoiced RO is closed (ro_status = 'I'): moving its stage puts a finished job back onto every board that selects on stage, so that is a 409 unless ?allow_closed=1 says you are tidying a stage left wrong at invoice time.

Two people moving the same card. A board is a shared surface and the DMS is editing the same rows underneath it, so if_current_status in the body is optimistic concurrency: the move proceeds only if the RO is still on the code the app last saw, and a 409 carrying the actual current code and stage otherwise. An app that sends it cannot silently overwrite a move somebody else made between the render and the drag; an app that omits it is choosing last-write-wins, which should be a decision rather than a default this endpoint made for it.

There is no ?probe=1 here, deliberately rather than by omission: this file already uses ?probe= as the catalog-probe toggle, so a second meaning would collide. The two things a write probe would answer are answered already — the valid codes by ?stages=1, the column this will stamp by filters.bound_columns.modified on any read. One statement, so no transaction: atomic on its own, and wrapping it would only add an exit path on which the connection could be left mid-transaction. Not yet run against a database — meta.measured is false. Run ?stages=1, then ?dry_run=1, then a live move on an RO you can afford to be wrong about.
KeyTypeRequiredDescription
dry_runflagopt1 = the statement, its bound parameter, the stage the RO is on now and the one it would move to, and whether the watermark would be stamped. Commits nothing.
allow_unknown_statusflagopt1 = write a code that is not in CODTYP WC. It will render as a blank stage on every board — for an install whose CODTYP is genuinely incomplete.
allow_closedflagopt1 = move the stage of an invoiced (ro_status = 'I') RO. Off by default: it puts a finished job back onto stage-driven boards.
stampflagopt0 = do not touch the last-modified column. Read the warning above first — the change then reaches no ?modified_since= consumer at all.
modified_colstringoptPin the column to stamp, when the probe cannot find one. Same parameter the read half binds its watermark with.
debugflagopt1 = per-query timing / size in a _debug block.

Body (JSON). branch and ro_number identify the RO — an RO is (branch, ro_number) in this DMS, never the number alone. ro_progress_status is the new CODTYP WC code (progress_status and ro_progress_code are accepted aliases, since the read emits one name and the filter uses another). if_current_status is optional and, when present, must equal the code the RO is on or the write is refused. Clearing the stage is not offered: an RO with no progress status is indistinguishable from one nobody has set yet, and it drops out of every board column at once.

json — drag a card to "Washing"
{ "branch": "M0101", "ro_number": 10234,
  "ro_progress_status": "40",
  // optional, and what makes a shared board safe: refuse if somebody
  // else moved this card between the render and the drag
  "if_current_status": "30" }
json
{ "status": "ok", "changed": true,
  "key": { "branch": "M0101", "ro_number": 10234 },
  "moved": { "from": { "ro_progress_code": "30", "ro_stage": "Awaiting Parts" },
              "to":   { "ro_progress_code": "40", "ro_stage": "Washing" } },
  "rows_affected": 1,
  // stamped, so this RO lands in every consumer's next delta — which is
  // how their copy of the stage gets corrected
  "sync": { "stamped": true, "stamp_column": "wkrofile.lop_datetime" },
  // the moved booking, shaped exactly as the list shapes it
  "booking": { "branch": "M0101", "ro_no": 10234, "booking_ref": "M0101-10234",
    "reg_no": "ABC123GP", "customer_name": "Sipho Ndlovu",
    "ro_progress_code": "40", "ro_stage": "Washing",
    "is_open": "Y", "category": "Due Today" },
  "meta": { "measured": false } }

// already on that stage — nothing written, and deliberately NOT stamped
{ "status": "ok", "changed": false, "ro_progress_status": "40" }
POST service_bookings.php ?update_req_datetime=1 &dry_run=1 &columns=both …

Moves one booking to another day or slot — the drag on a bookings chart, or a date picked off a job's menu. Keyed (branch, ro_number), one RO per request. Without this the gesture is on screen only: the card sits on the new day until the next poll puts it back, which is the worst kind of wrong because it looked like it worked.

There are two booked-day columns on WKROFILE, and this feed does not read the obvious one. datetime_in here is EXPECTED_DATETIME (falling back to CREATION_DATE) — req_datetime appears nowhere in this file. The split is deliberate and long-standing: Workshop Summary, Service Due, Capacity, Comebacks, Quotes and Booking Reliability probe req_datetime first; this feed, WIP and the WIP detail feeds use a fixed expected_datetime. So a write that set only req_datetime would move the job on every capacity and summary report and leave it exactly where it was on the board the app polls. This sets both by default, and ?columns=req|expected is there for a caller who has decided otherwise. meta.slot.columns_written always reports what was actually set.

It also tells you whether the two were in step before the write. slot.were_in_step is evidence nobody in this codebase has collected: if the columns already disagreed on that RO, this DMS does not keep them together on its own, and every report split across the two camps has been quietly describing different days. It rides on every response and on the dry run, whichever way it comes back.

The time of day is part of the value and is stored exactly as sent, to the second. Nothing here applies a timezone, shifts an hour or re-renders the value — these are workshop wall-clock times. Send the whole YYYY-MM-DD HH:MM:SS with the original time carried across and only the date changed; a date on its own is written as midnight. A midnight slot is accepted and flagged rather than refused: a client that reads 00:00:00 as "a day with no slot" will show the booking that way, and notes says so — but midnight is also exactly what an unslotted day means, and refusing it would make that unexpressible.

Refused: an invoiced RO (409 with ro_status — a closed job's booked date is history, not a plan, and there is no override); one whose vehicle has been picked up — RO_PROGRESS_STATUS = VP, the same code Workshop Summary buckets its day by. That is a gate in its own right and not only alongside an invoiced RO, because a car can be collected before the invoice is raised and it has still gone; ?allow_collected=1 is there for a genuine correction and ?collected_status= remaps the code, since those letters are per-install. Also refused: a quote (409 — a quote is dated by creation_date here, so writing either datetime column moves nothing the card is placed by and the move would spring back with a 200 in your log); a date before the RO was created (?allow_backdate=1) or more than three years out (?allow_far_future=1), both typed-year guards rather than policy; and an install carrying neither column. if_current_req_datetime is the optimistic lock and is compared against datetime_in — the value the card was showing — not against req_datetime, which this feed never displayed. if_current_datetime_in is accepted as the clearer name.

It stamps the sync watermark, by the same rule as the progress-status write above: a move that did not would never reach a ?modified_since= consumer, so every other copy of the board would keep the old day indefinitely. A no-op — every target column already on that value — writes nothing and stamps nothing. One statement, no transaction. Not yet run against a database; meta.measured is false.
KeyTypeRequiredDescription
update_req_datetimeflagreq1 — the mode gate. A POST without it is the progress-status write, told apart by an explicit flag rather than by what the body happens to contain.
dry_runflagopt1 = the statement, its bound params, the move and were_in_step. Writes nothing.
columnsstringoptboth (default) · req · expected. Anything but both leaves the two halves of this API disagreeing about the job, and the response says so in notes.
allow_collectedflagopt1 = move an RO whose vehicle has already been picked up (VP). Off by default: the car has gone and its booked day is a fact about the past.
collected_statusstringoptWhich progress code(s) mean collected. Default VP. Per-install letters — keep it in step with Workshop Summary's status_picked_up.
allow_backdateflagopt1 = permit a slot before the RO was created — for correcting a booking keyed with the wrong year.
allow_far_futureflagopt1 = permit a slot more than three years out.
stampflagopt0 = do not stamp the last-modified watermark. The move then reaches no ?modified_since= consumer.
debugflagopt1 = per-query timing / size in a _debug block.

Body (JSON). branch and ro_number identify the RO (ro_no accepted). req_datetime is the new slot — datetime_in and expected_datetime are accepted as aliases, since the app echoes back the name the read gave it. if_current_req_datetime optional. Clearing a slot is not offered: a booking with no date is not a booking, and the feed would have nothing to place it on.

json — drag Wednesday's 07:30 job to Friday
{ "branch": "M0101", "ro_number": 10234,
  "req_datetime": "2026-09-11 07:30:00",
  "if_current_req_datetime": "2026-09-09 07:30:00" }
// carry the original time of day across and change only the date —
// a move to "2026-09-11" alone lands at midnight and reads as "no slot".
json
{ "status": "ok", "changed": true,
  "key": { "branch": "M0101", "ro_number": 10234 },
  "slot": {
    "columns_written": ["REQ_DATETIME", "EXPECTED_DATETIME"],
    "from": { "req_datetime": "2026-09-09 07:30:00",
              "expected_datetime": "2026-09-09 07:30:00" },
    "to": "2026-09-11 07:30:00",
    // false here means this DMS was already carrying two different
    // booked days for the same job — worth chasing across the file
    "were_in_step": true },
  "rows_affected": 1,
  "sync": { "stamped": true, "stamp_column": "wkrofile.lop_datetime" },
  "notes": [],
  // field for field what the list returns for this RO
  "booking": { "branch": "M0101", "ro_no": 10234,
    "datetime_in": "2026-09-11 07:30:00", "category": "Future Booking",
    "reg_no": "ABC123GP", "ro_stage": "Awaiting Parts" },
  "meta": { "measured": false } }
POST service_bookings.php ?update_advisor=1 &dry_run=1 …

Puts one repair order on a service advisor — writes WKROFILE.SALESMAN, the column this feed reads back as sales_advisor_code (and joins to CONTACT for the name). Built for ServiceConnect (2.635.0), which allocates every job card to a service advisor first and then divides each advisor's jobs among technicians; the DMS has to name the same advisor, or the RO printed at the counter says somebody else. Keyed (branch, ro_number), one RO per request, WRITEKEY header.

The code must be on VhSalman and not terminated (?allow_terminated=1 to correct an old RO) — an unknown code writes happily and then reads back as a blank name, which looks like nobody. Clearing is not offered. Refused: an invoiced RO (409, ?allow_closed=1); if_current_advisor not matching (409 with current; null means the card showed nobody). A no-op writes nothing and stamps nothing. It stamps the sync watermark like the other two writes (?stamp=0 to skip). One statement, no transaction. Not yet run against a database.

KeyTypeRequiredDescription
update_advisorflagreq1 — the mode gate. A POST without it is the progress-status write.
dry_runflagopt1 = the statement, its bound param and the move. Writes nothing.
allow_closedflagopt1 = write an invoiced RO.
allow_terminatedflagopt1 = accept an advisor terminated on VhSalman.
stampflagopt0 = do not stamp the last-modified watermark.

Body (JSON). branch, ro_number (ro_no accepted), advisor_code; if_current_advisor optional. Any other field is a 400.

json
{ "branch": "M0101", "ro_number": 10234, "advisor_code": "1042", "if_current_advisor": "1001" }
json
{ "status": "ok", "changed": true,
  "key": { "branch": "M0101", "ro_number": 10234 },
  "moved": { "from": "1001", "to": "1042" },
  "advisor_code": "1042", "advisor_name": "Piet Advisor", "rows_affected": 1,
  "sync": { "stamped": true, "stamp_column": "wkrofile.lop_datetime" } }
POST service_bookings.php ?update_confirmed=1 &dry_run=1 …

Marks one repair order confirmed by the customer — writes WKROFILE.Confirmed_Status, 'Y' or 'N'. The column is on every card the UNITS screen raises and it is what the workshop reads to know the customer has agreed to the work; until now nothing outside that screen could set it. Built for ServiceConnect (2.680.0), which sets it the moment the customer accepts their check-in report in the app: the acceptance IS the confirmation, and a car sitting confirmed on one system and unconfirmed on the other is how work waits for a phone call nobody needed to make. Keyed (branch, ro_number), one RO per request, WRITEKEY header.

It is the only column touched. The terms, the signature and the check-in itself live in ServiceConnect; this says one thing about one card. confirmed is optional and true is what is meant — a caller who has bothered to call this is confirming. Refused: an invoiced RO (409, ?allow_closed=1 to correct one), because confirming a card that has already been billed is either a mistake or a correction and the two should not look alike. A no-op writes nothing and stamps nothing. It stamps the sync watermark like the other writes on this file (?stamp=0 to skip). One statement, no transaction. Not yet run against a database.

KeyTypeRequiredDescription
update_confirmedflagreq1 — the mode gate. A POST without it is the progress-status write.
dry_runflagopt1 = the statement, its bound param and the move. Writes nothing.
allow_closedflagopt1 = write an invoiced RO.
stampflagopt0 = do not stamp the last-modified watermark.

Body (JSON). branch, ro_number (ro_no accepted); confirmed optional, defaults to true. Any other field is a 400.

json
{ "branch": "M0101", "ro_number": 10234, "confirmed": true }
json
{ "status": "ok", "changed": true,
  "key": { "branch": "M0101", "ro_number": 10234 },
  "moved": { "from": "N", "to": "Y" },
  "confirmed": "Y", "rows_affected": 1,
  "sync": { "stamped": true, "stamp_column": "wkrofile.lop_datetime" } }

Workshop Summary

What a branch does on an average day of the week. One row per branch per weekday over a rolling 13-week benchmark window: repair orders booked in, ROs on the floor, hours clocked, hours sold, technicians. Jobcards closed at zero — no-shows — are left out of every figure. The shape a roster, a booking target or a capacity plan gets measured against. Served by workshop_summary.php.

GET workshop_summary.php ?branch= &weeks= &group_by= …

Two date bases, deliberately not reconciled. ros_booked is bucketed on WKROFILE.REQ_DATETIME — the day the vehicle is booked to come in. Everything else is bucketed on the clocking date, ISNULL(WKMECHWK.DATE_CLOCKED_IN, START_TIME) — the day someone turned a spanner. An RO booked Monday and worked over three days is one Monday booking and three days on the floor, so the two counts do not sum to the same number and nothing here divides one into the other.

An average Tuesday divides by the Tuesdays the branch actually traded, not by the 13 Tuesdays on the calendar — a branch that opens Saturday mornings is not doing a fifth of a Saturday. ?denominator=calendar switches basis; days.calendar, days.worked and days.booked ride on every row either way, so the other average is one division away. A weekday with no trading days reports null, never 0.
A jobcard closed at zero is not intake, and is excluded by default (new 2026-09-01). A car is booked, an RO is opened, the car never turns up, and the branch closes the card off. Counted, that no-show inflates the weekday shape — and unevenly, so a branch with a poor show-up rate reads as busier than one that fills its book. It is excluded from every figure here bar the today block: the intake counts, the make counts, and any hours clocked against it. One is recognised on the invoice register, joined to the jobcard on (BRANCH, RO_NUMBER), and both halves of the signature are required: exactly one WKINVREG row for that RO, and that row's RO_TYPE = 'E'. Either half alone is wrong — a lone row of another type is an ordinary one-document job, and an E row sitting alongside two others is an E line on a real job. summary.zero_closed_ros reports how many the window holds on every call, so the gap against a raw WKROFILE count is never unexplained; ?zero_closed=include restores the old figures and ?zero_closed=only turns the whole endpoint into the no-show report.

Both bases are filtered, and 'E' is checked rather than believed. ros_booked, quote_ros, ros_undated and ?counts_only=1 on the intake side; ros_worked, hours_worked, hours_invoiced, hours_rework, technicians and labour_lines on the clocking side. Such a card usually has nothing clocked to it at all — but where it does, ros_worked would otherwise credit it with a bay it never occupied, and those are hours against work that did not happen. The today block is the one exception: today's cars are not invoiced yet, so the signature cannot have formed. Watch the cost on the clocking leg — every other application of the rule probes once per RO, that one probes once per clocking line, on the largest table this endpoint reads; it seeks AITONE_WKINVREG_BR_RO, but compare ?debug=1 against ?zero_closed=include on a large install rather than assuming. As for the code itself —wkinvreg.RO_TYPE is CHAR(1) and a different domain from the za_* ro_type varchar status (Invoice/Reversal) that Workshop Sales warns about; the names collide and mean nothing to each other. ?schema=1 censuses every jobcard in the window by how many invoice rows it carries, with the type on the single-row ones, so the binding is confirmed on the install before any figure depends on it. ?zero_closed_type= remaps it.

Excluded from every average, but carried as data — ro_type_e. The cards come out of ros_booked and out of the labour figures, and are then handed straight back at three grains, so nothing has to be inferred or fetched twice: data[].total.ro_type_e is how many that branch had on that weekday, data[].avg.ro_type_e is the same per calendar occurrence of it, summary.ro_type_e is the window total, and zero_closed_by_date[] is the daily series — branch · date · ro_type_e · quote_ros, unpaginated, only for days that actually hold one. None of it is ever added back into ros_booked, ros_worked or any other average. That exists so nobody has to call twice, once with ?zero_closed=only and once without, and then reconcile two windows fetched at different moments.

avg.ro_type_e is the one average that ignores ?denominator= — it always divides by days.calendar. The active denominators exist because a day the branch did not open is not a bad day, it is not a day; but that argument is about dividing work, and a no-show is not work. Worse, days.booked counts only days that produced a surviving booking, so a day whose entire book failed to arrive would put its no-shows in the numerator and nothing in the denominator — and a weekday that was a washout every single time would divide by zero and report null on the one day it most needs a number. Calendar occurrences are fixed, known in advance and identical across branches, which is what a rate compared between branches has to be. days.booked and days.worked are on every row if you want to re-divide.
The field name says E; the binding does not have to. ro_type_e is named after the default code because that is what a downstream consumer keys on — but it carries whatever ?zero_closed_type= binds. meta.zero_closed.ro_types always reports what that actually was, so check it rather than the field name on an install that closes no-shows off under a different code.

The today block is the board. Alongside the averages, the response opens with the ROs booked in for today (database clock), counted by their current RO_PROGRESS_STATUS: book_in (BI), vehicle_arrived (VA), vehicle_picked_up (VP) and other — with every other status broken out one line each under by_status, named from CODTYP type WC, because what's in it (awaiting parts, awaiting authority, washing) is the interesting part. Per branch and totalled. It sits deliberately outside the benchmark window and is never folded into summary or data — today is a partial day, which is what makes it a board and not a benchmark. ?today_date= runs it for another day, ?today=0 drops it. Scoped to cars due in on the day, so a vehicle booked Tuesday and still on site Thursday is not in Thursday's block; for what is physically in the yard, use Work In Progress. No arrival or completion rate is derived, since that would mean assuming which side of arrival every other status sits on.

?counts_only=1 is a different report entirely — one flat grain, branch · service_date · make · make_cnt over ?date_from..?date_to, and nothing else runs. ROs per make per day. One thing differs from the obvious hand-written version: the date range includes both named dates, where DATE(col) > from AND DATE(col) < to excludes both — and full-scans, being a function over an indexed column. Expect counts two days wider than that form for the same parameters. WKVEHFL is joined directly on REG, its primary key, so nothing can fan out; because two older files here assume the opposite, every call also runs a window-scoped probe and reports summary.vehicle_reg_duplicates — anything above 0 means REG is not unique after all, and meta.warning says the counts are inflated rather than letting a plausible-looking number stand. ?vehicle_join=dedup then re-runs it fan-out-proof. Quotes are excluded by default as everywhere else here; ?include_quotes=1 matches a raw query with no quote filter, and make_cnt_incl_quotes is on every row either way. make is uppercased and trimmed; NF means no vehicle row for that reg, or a blank make. The no-shows are reported here too: summary.by_date[].ro_type_e sits beside that date's ros, summary.ro_type_e is the window total, and zero_closed_by_date[] keeps the branch that by_date folds away. A date whose entire book failed to arrive still appears in by_date, with ros: 0 and a non-zero ro_type_e, rather than dropping out of the series. It is not broken out by make — attributing an excluded card to a make means running the vehicle join over the excluded set, and ?counts_only=1&zero_closed=only already is that report: same grain, same join, scoped to the no-shows.

The default window is the last 13 complete weeks ending yesterday on the database clock — 91 days holds exactly 13 of each weekday, where "three calendar months" holds 13 of some and 14 of others and tilts the calendar denominator by ~8% on an arbitrary half of the week. Yesterday, not today, because a call at 09:00 would fold three hours into today's weekday as though it were a whole day. This is a planning shape, not a ledger: for labour sales, Workshop Sales is the authority and will not tie.

KeyTypeRequiredDescription
weeksintegeroptWhole weeks back from yesterday, 1–53. Default 13.
date_fromdateoptYYYY-MM-DD. Overrides weeks; whole weeks are then no longer guaranteed and meta.window.balanced says so.
date_todateoptYYYY-MM-DD. Alone, it still means "the same number of whole weeks, ending there".
branchstringoptComma list. branch_include is the alias; branch_exclude removes.
technician_codestringoptRestricts the labour side only (mechanic_code accepted). ros_booked is unaffected — intake has no technician.
denominatorstringoptactive (default) = days the branch traded | calendar = every occurrence of that weekday in the window
group_bystringoptbranch (default) = seven rows a branch | weekday = one set of seven across every branch in scope
include_quotesflagopt1 = count quotation ROs in ros_booked. Off by default — a quote is not a job. Counted as quote_ros either way.
reversalsstringoptnet (default) | include | exclude | only. A reversal carries the negative of the line it reverses, so summing both sides is the netting. exclude drops the reversal and keeps the original, and therefore overstates.
zero_closedstringoptexclude (default) drops jobcards closed at zero — usually no-shows: exactly one WKINVREG row for the RO, and that row RO_TYPE = 'E'. | include counts them, which is what this endpoint did before 2026-09-01. | only reports nothing but those cards — the no-show report, by branch and by weekday. Applies to both bases: ros_booked, quote_ros, ros_undated and counts_only on the intake side, and ros_worked, hours_worked, hours_invoiced, hours_rework, technicians and labour_lines on the clocking side. The today block is the one exception — those cars are not invoiced yet. Excluded but not hidden: the count comes back as ro_type_e on every weekday row under total and under avg (that one always over days.calendar, the only average that ignores denominator), in summary for the window, and at branch · date grain in zero_closed_by_date[]. Under counts_only it rides on summary.by_date beside that date's ros. Reported in every mode, never added back into any count or average.
zero_closed_typestringoptThe wkinvreg.RO_TYPE code(s) that mark a card closed at zero. Comma list, default E. A different domain from the za_* ro_type status — run ?schema=1 to see which code this install actually uses before changing it.
ro_date_basisstringoptPin the WKROFILE booking-date column. Default binds req_datetime → expected_datetime → creation_date, whichever exists; meta.ro_date_basis always reports which and whether it was a fallback.
counts_onlyflagopt1 = ROs per make per day over date_from..date_to, and nothing else. One flat grain: branch · service_date · make · make_cnt, plus by_make / by_date rollups — by_date carrying ro_type_e beside its ros, and a zero_closed_by_date[] block keeping the branch it folds away. Honours branch, include_quotes and zero_closed; ignores group_by, denominator, detail and the today block.
vehicle_joinstringoptcounts_only only. direct (default) joins WKVEHFL on its REG primary key and seeks the index | dedup groups WKVEHFL to one row per REG first, so a duplicate registration cannot multiply a count. Use dedup if summary.vehicle_reg_duplicates ever comes back above zero.
detailflagopt1 = also return the per-date rows the averages were folded from, so any figure can be traced to its days
todayflagopt0 = drop the today block. On by default.
today_datedateoptRun the today block for another day — tomorrow's board, or yesterday's for a post-mortem. Defaults to today on the database clock.
status_book_instringoptCodes counted as Book In. Default BI. Comma list, so several codes fold into one bucket.
status_arrivedstringoptCodes counted as Vehicle Arrived. Default VA.
status_picked_upstringoptCodes counted as Vehicle Picked Up. Default VP. A code listed under two params binds to the first — never counted twice.
schemaflagopt1 = the live WKROFILE column list, which date column bound, the RO_PROGRESS_STATUS census — the codes actually in use over the window with their descriptions and current bucket — and the zero-closed census: every jobcard in the window by how many WKINVREG rows it carries, with the RO_TYPE on the single-row ones and an excluded flag per line. Run once per install, then remap the status buckets or zero_closed_type if the codes differ.
pageintegeroptPage number
limitintegeroptRows per page. Blank = all (at most 7 per branch).
json
{ // the board — today only, deliberately outside the window below
  "today": { "date": "2026-08-26", "day_name": "Wednesday",
    "totals": { "ros_total": 25, "book_in": 6, "vehicle_arrived": 11,
                 "vehicle_picked_up": 3, "other": 5, "quote_ros": 3,
      // every other status one line each, never a lump
      "by_status": [{ "code": "BI", "description": "Book In", "bucket": "book_in", "ros": 6 },
                    { "code": "WS", "description": "Washing", "bucket": "other", "ros": 3 },
                    { "code": "AP", "description": "Awaiting Parts", "bucket": "other", "ros": 2 }] },
    "branches": [{ "branch": "M0101", "ros_total": 18, "book_in": 5, "vehicle_arrived": 5,
                   "vehicle_picked_up": 3, "other": 5 }] },
  "meta": {
    "window": { "from": "2026-05-27", "to": "2026-08-25", "days": 91,
                "weeks": 13, "balanced": true },
    "ro_date_basis": { "column": "REQ_DATETIME", "is_fallback": false },
    // both halves of the signature, and how many it removed
    "zero_closed": { "mode": "exclude", "ro_types": ["E"], "ros": 37,
        "signature": "exactly one WKINVREG row for (BRANCH, RO_NUMBER), and that row RO_TYPE in the codes above",
        "applied_to": "BOTH bases — the WKROFILE intake counts and the WKMECHWK clocking figures",
        "not_applied_to": "the today block only — those cars are not invoiced yet" },
    "denominator": "active" },
  "summary": { "branches": 1, "hours_worked": 4180.5, "efficiency_pct": 104.2,
    // excluded from every bucket: no booking date to place them on
    "ros_undated": 4,
    // no-shows removed from every figure — reported in every mode
    "ro_type_e": 37,
    "busiest_day": { "day_name": "Tuesday", "hours_worked": 910.0 } },
  "data": [{ "branch": "M0101", "day_of_week": 2, "day_name": "Tuesday",
    // three denominators, so you can re-divide without another call
    "days": { "calendar": 13, "worked": 12, "with_clocking": 12, "booked": 13 },
    // no-shows are not inside ros_booked — they sit beside it
    "avg": { "ros_booked": 9.2, "ros_worked": 14.6, "hours_worked": 75.8,
             "hours_invoiced": 79.0, "technicians": 9.1,
             // 4 / 13 calendar Tuesdays — ignores ?denominator= by design
             "ro_type_e": 0.3 },
    "total": { "ros_booked": 120, "ros_worked": 175, "quote_ros": 14,
               // this Tuesday's no-shows — reported, never added back in
               "ro_type_e": 4,
               "hours_worked": 910.0, "hours_invoiced": 948.0 },
    "efficiency_pct": 104.2, "hours_worked_per_ro": 5.2 }],
  // the daily series, branch · date grain — only days that hold one
  "zero_closed_by_date": [
    { "branch": "M0101", "date": "2026-06-02", "day_of_week": 2,
      "day_name": "Tuesday", "ro_type_e": 4, "quote_ros": 1 }] }

Capacity & Load

How full is Thursday. One row per branch per date over a forward window, carrying the two halves nothing else here puts together: hours_available — what the shop is rostered to be able to do, read from WKMECHADJ — against hours_required, what the open book says has to be done, from WKOTHSUB. Every other workshop feed answers a question about value; this one is about time, which is the only thing a controller can actually spend. Served by workshop_capacity.php.

GET workshop_capacity.php ?date_from= &days= &branch= &group_by= …
This is not Workshop Summary, and the two will not tie. That one is a shape, backwards — seven rows a branch, what an average Tuesday looked like over 13 weeks ending yesterday, on hours clocked (WKMECHWK). This is a plan, forwards — actual dates from today, on hours rostered (WKMECHADJ) against the open book. Nothing is computed twice and no figure here derives from that one. Two pairs look like they should reconcile and do not: hours_worked there against hours_available here is clocked time against rostered time (their ratio is utilisation, which is on neither feed), and technicians there counts who clocked where here it counts who had an attendance row — a technician rostered who clocked nothing is in the second and not the first. Use Summary to decide what a Tuesday is worth planning for, and this to see whether next Tuesday is already full. The no-show exclusion is not repeated here and does not need to be: a jobcard closed at zero has been invoiced, so its header reads 'I' and the open gate has already dropped it — structural, not an oversight.
Two booking-date columns exist on WKROFILE, and this endpoint is on the intake one. Workshop Summary and Service Due probe req_datetime first — the day the vehicle is booked to come in, and the only one of the two anybody has confirmed. Service Bookings, WIP and WIP Labour use a fixed expected_datetime. This board takes the first, deliberately: it is asking which day a car occupies a bay, and "the day the branch expects the car on the premises" is that day. The cost is real and is not hidden — where the two columns hold different values, this board and the WIP board will place the same RO on different days, and a dashboard showing both will look like it is contradicting itself. meta.ro_date_basis reports the bound column on every response; ?ro_date_basis=expected_datetime moves onto the other camp's column in one parameter.

Two owners, one join, and that is why it is its own endpoint. The supply side belongs to Technician Attendance and the demand side to Service Bookings — putting either question inside the other file would turn a roster feed into a booking feed or hang a roster read off a poller that runs every five minutes. So this borrows both definitions verbatim rather than restating them: the availability rule (AD only, hours read from the DMS and never modelled from an assumed eight-hour day), the booking slot, and the open gate — an uninvoiced WKOTHSUB line on an RO whose ro_status <> 'I', the same gate Work In Progress uses. Change one of those and change it there first.

Read meta.coverage.jobs_estimated_pct before you read load_pct. A job's hours resolve through a ladder — 1. job, the estimated-hours column on WKOTHSUB; 2. job_code, WkCodeFl.EST_HOURS for that operation; 3. assumed, only if you pass ?assume_hours=; 4. unknown, which contributes zero hours and is counted in jobs_unestimated. Nothing invents a number. On an install where neither column carries hours, every load figure is zero and the board is telling you about your data — meta.coverage.warning fires below 50% and ?schema=1 names the column to pin. A stored zero is indistinguishable from an unset column, so both real rungs test > 0: a job genuinely estimated at zero falls to unknown, where it is at least visible.
There is deliberately no "average hours per job code from history" rung. It was the obvious fourth, and it would need a date-ranged aggregate over WKMECHWK — the largest table these feeds touch — which has no index a job-code rollup could seek. Shipping an unmeasured scan into a board a controller refreshes all day is how a dashboard becomes the reason nobody uses the dashboard. It wants its own registry entry and a measured dry run first, not a guess.

Today carries its own bookings plus everything that should already have been finished. An RO's outstanding hours land on exactly one day — nothing spreads a job across days, because a multi-day job's split is not recorded anywhere in the DMS. Work slotted inside the window lands on its slot date as hours_booked; work slotted before the window lands on the window's first day as hours_backlog, because it still has to be done and still eats a bay. Both are on every row and hours_required is their sum, so neither setting of ?carry_backlog= can make the number ambiguous. That backlog is the single biggest reason a shop that looks 60% booked is three days behind, and it is exactly what a booking-only board hides. ?backlog_max_age=90 (default) keeps a two-year-old abandoned RO from dumping its hours onto today — that is an admin problem, not tomorrow's work; chase it with WIP's age bands.

hours_required is what is left, not what was estimated. Hours already clocked against each open job line (WKMECHWK.HOURS_WORK, at the same four-part grain WIP Labour Detail joins on) are subtracted, floored at zero. Two things about that are true and neither is fatal: it mixes an estimate with clocked wall time, and an overrun reads as "no work left" rather than as negative capacity. The alternative is a board on which every job on the floor consumes its whole estimate again every day it is carried — wrong by more, every day, in the same direction. hours_clocked rides on every row so the size of the adjustment is never hidden; ?net_clocked=0 turns it off.

load_pct comes back null rather than wrong. Two cases. A franchise filter is active: it narrows the work but not the people — WKMECHADJ carries no franchise, and a technician's day is not divisible by brand — so the ratio would compare a slice of the demand against all of the capacity and read as a comfortably empty shop. load_pct, free_hours and the three load-derived statuses all suppress; meta.ratio_suppressed says so in words. Nobody is rostered: dividing by zero is not a big number, it is not a number — so status carries it instead, and closed_with_work (jobs booked, no technicians) is the alert this board exists to raise.

?group_by=ro is the drill-down behind a day's number — which repair orders make up Thursday's 41 hours, each with its slot date, the day it lands on, whether it is booked or carried backlog, how late it is, and the ladder rungs its job lines resolved through, so a suspicious total can be argued with rather than only disbelieved. The three rolled-up grains emit every date in the window for every branch, empty ones included: a capacity board that omits the days with no roster cannot show you the day you are about to book into. has_capacity_record separates "the roster says nobody works that day" from "nobody entered a roster" — only the second is recoverable by going and looking.

No live timings yet. Every timing claim elsewhere in this reference came off ?debug=1 against a real DMS; this endpoint has none, and meta.measured is false until one is recorded. The demand query — one row per open job line — is the only unbounded thing in it. Run it with ?debug=1 before wiring it to anything on a timer. No new indexes are needed: it rides AITONE_WKOTHSUB_RO_INV, AITONE_WKROFILE_BR_RO, _EXPECTED, _CREATED, AITONE_WKMECHWK_RONUM and AITONE_WKMECHADJ_DATE_TECH, all already in the registry.
KeyTypeRequiredDescription
date_fromdateoptYYYY-MM-DD. Default today on the database clock — forward by default, unlike every other workshop feed, because this is the only one whose subject has not happened yet.
daysintegeroptWindow length from date_from, 1–180. Default 14. Ignored when date_to is given.
date_todateoptYYYY-MM-DD, inclusive. Capped at 180 days: the board is branch × date and past the roster horizon every extra row is hours_available: 0.
group_bystringoptday (default) = branch × date, the board | date = one row per date across branches | branch = one row per branch over the window, with first_slack_date and first_overload_date | ro = the contributing repair orders
branchstringoptComma list, applied to both sides — the RO's BRANCH and the attendance row's TECH_BRANCH, which is where the hours were actually available, so a technician lent to another branch counts for the branch receiving them. branch_include is the alias; branch_exclude removes.
franchisestringoptRO franchise, comma list. Slices the demand only — WKMECHADJ has no franchise — so load_pct, free_hours and the load statuses suppress while it is active. hours_required is then the slice and hours_available the whole shop.
carry_backlogflagopt0 = do not carry overdue open work onto the window's first day. On by default. Carried or not, it is reported in summary.backlog, and what 0 dropped in summary.backlog_excluded.
backlog_max_ageintegeroptIgnore open work whose slot is more than N days before the window. Default 90; 0 = no cap. Work outside it is never read, so widen the cap rather than looking for it in the summary.
net_clockedflagopt0 = do not subtract hours already clocked, so a half-finished job consumes its whole estimate again. On by default.
assume_hoursnumberoptHours to assume for a job with no estimate at all, 0–24. Off by default and never a default — an assumption is the caller's to state, and meta.assume_hours reports it when made.
available_typesstringoptWKMECHADJ TYPEs counting toward availability. Default AD (normal working day). A public holiday means the day is accounted for, not that capacity existed. Run Technician Attendance?types=1 for the census on your install.
include_quotesflagopt1 = count quotation ROs as booked work. Off by default, matching WIP — a quote is not a booking.
full_pctnumberoptThe load_pct at which a day reads full rather than open. Default 85. Above 100 is always overloaded.
include_terminatedflagopt1 = keep technicians terminated as at date_to. Off by default, the same rule the roster and attendance feeds apply.
ro_date_basisstringoptPin the WKROFILE booking-date column — which day an RO lands on. Default binds req_datetime → expected_datetime → creation_date: Workshop Summary's ladder, not Service Bookings' / WIP's fixed expected_datetime. Pass expected_datetime to line this board up with those two instead. meta.ro_date_basis always reports what bound.
est_hours_colstringoptPin the WKOTHSUB estimated-hours column. Default binds est_hours → estimated_hours → chargeable_hours. That order is the opposite of Invoices', deliberately: there the question is what to bill, here it is what a day will cost the floor, and on an open job a chargeable figure is a price rather than a forecast.
work_cat_colstringoptPin the WKOTHSUB work-category column. When none exists the WkCodeFl map is keyed on the job code alone, and a code whose categories disagree about EST_HOURS is treated as unestimated rather than resolved to an arbitrary one — counted in meta.coverage.ambiguous_codes.
schemaflagopt1 = the live column lists for WKOTHSUB and WkCodeFl, the hours_like_columns shortlist, what bound and from where, how many job codes actually carry standard hours, and which codes are ambiguous. The one call whose job is to answer "why is jobs_estimated_pct zero on my install".
pageintegeroptPage number
limitintegeroptRows per page. Blank = all.
json
{ "meta": {
    "grain": "one row per branch per date", "group_by": "day",
    "window": { "from": "2026-09-02", "to": "2026-09-15", "days": 14 },
    "today": "2026-09-02", "today_basis": "database clock (GETDATE())",
    "available_types": ["AD"],
    "hours_basis": "availability READ from WKMECHADJ, never modelled from an assumed working day",
    // which column carried the estimate, and whether it was a fallback
    "estimate_source": { "column": "EST_HOURS", "source": "probed SYSCOLUMN",
                         "fallback": false },
    // READ THIS BEFORE load_pct — an unknown job contributes ZERO hours
    "coverage": { "jobs": 412, "jobs_estimated_pct": 87.4,
                   "by_rung": { "job": 301, "job_code": 59,
                                "assumed": 0, "unknown": 52 },
                   "job_code_keys": 1184, "ambiguous_codes": 7 },
    "carry_backlog": true, "backlog_max_age": 90, "net_clocked": true,
    "ratio_suppressed": null, "measured": false },
  "summary": { "rows": 28, "hours_available": 1064.0, "hours_required": 918.5,
    "hours_booked": 742.0, "hours_backlog": 176.5, "load_pct": 86.3,
    "free_hours": 145.5, "ros": 203, "jobs_unestimated": 52,
    // where the carried work came from — never silently folded in
    "backlog": { "ros": 31, "hours": 176.5 },
    "estimate_basis": { "job": 301, "job_code": 59, "assumed": 0, "unknown": 52 } },
  "data": [
    { "branch": "M0101", "branch_name": "DURBAN", "date": "2026-09-02",
      "weekday": "Wednesday", "is_today": true, "has_capacity_record": true,
      "is_working_day": true, "technicians": 9, "technicians_working": 8,
      "hours_available": 72.0,
      // today's own bookings, plus everything already behind
      "hours_booked": 54.5, "hours_backlog": 23.0, "hours_required": 77.5,
      "hours_clocked": 18.25, "ros": 21, "jobs": 34, "jobs_unestimated": 3,
      "load_pct": 107.6, "free_hours": -5.5, "status": "overloaded" },
    // jobs booked onto a day nobody is rostered for — the alert
    { "branch": "M0101", "date": "2026-09-05", "weekday": "Saturday",
      "has_capacity_record": false, "is_working_day": false, "technicians": 0,
      "hours_available": 0.0, "hours_required": 6.5, "ros": 2,
      "load_pct": null, "free_hours": null, "status": "closed_with_work" }] }

Comebacks

The same car, back again, too soon. One row per (prior job, return job) pair on the same registration, with the gap between them and the evidence for and against calling it a comeback — plus the rate, on an honest denominator. This is the one metric on the workshop tab that measures whether the work was any good rather than how much of it there was, and nothing else in this API touches it. Served by workshop_comebacks.php.

GET workshop_comebacks.php ?within_days= &rule= &group_by= &branch= …
The DMS does not record comebacks. This infers them, and says how. There is no comeback flag on any table here, so every figure is inferred from three facts — same vehicle, short gap, and what the return job looks like. Inference is exactly where a workshop metric goes wrong in a way nobody catches, because a plausible number attached to a technician's name gets acted on. So no single rule is baked in: each pair carries three independent signals and ?rule= chooses the combination. summary.by_rule scores all five rules over the same candidates on every call, so you pick the rule from your own data rather than from a guess — and re-cut without a second fetch.

The three signals, and how much each is worth. same_job_code — the return shares a job code with the prior. Weak alone: the codes here are coarse (the friendly buckets are Sublet / Repair / Service), so two "Repair" jobs a fortnight apart may be a comeback or may be a wheel bearing and a wiper motor. return_no_charge — the return carries a job type the shop eats: Internal, Warranty or Policy. The strongest signal: a customer paying full retail for a second visit is usually not coming back about the first one; a shop redoing work at its own cost usually is. same_technician — not evidence that it is a comeback, but the thing that makes one actionable, and why technician attribution sits on the pair rather than being derived downstream.

A prior must be invoiced and clocked — it has a WKINVREG row (and MAX(work_date) is when the car went out, so the clock starts when the customer drove away) and at least one WKMECHWK line, because a comeback is a return after work was done. That second test is doing more than it looks: in one predicate it removes the jobcards closed at zero that Workshop Summary identifies as no-shows, parts-only counter transactions billed through a jobcard, and closed-off quotations. None of those is a repair, and every one would have manufactured a comeback out of an ordinary next visit.

The rate is not comebacks ÷ jobs. A job invoiced yesterday has not yet had its thirty days to come back in, and counting it drags the rate down by exactly the proportion of the window that has not elapsed — worst at the recent end, which is the end anybody looks at, and invisible. So the denominator is eligible priors only: jobs whose whole within_days window has already passed as at today on the database clock. That is the honest-denominator rule service_retention.php applies to its sale cohorts, used here on the same problem. Returns against a prior whose window has not elapsed come back separately as in_progress, so early signal is not lost and can never inflate a rate.
comebacks and pairs count different things. One prior can have several returns and each is its own row; summary.comebacks counts distinct priors, so a job that came back twice is one comeback and two pairs. Adding up the rows will give a bigger number than the summary — that is the two figures measuring different things, not one of them being wrong. Same on the technician grain: a job two technicians shared counts for both, so those rows do not sum to the window totals. Correct for a per-person rate, wrong for a total.

Four things it cannot see, stated rather than guessed at. (1) The vehicle is the registration — WKROFILE carries no VIN, so a transferred personalised plate reads as one vehicle and a re-registered car as two, and there is no second key on that table to cross-check with. (2) Nothing here reads a complaint. Two visits about genuinely different faults inside thirty days are indistinguishable from a comeback except through the job code, and the job codes are coarse — which is why the rule is a parameter. (3) A return to another branch is still a return and is matched; cross_branch flags it, and a branch filter narrows the prior side only, because hiding it would lose the customer who gave up on one branch and tried another. (4) Job codes come off the live WKOTHSUB, whose rows are replaced when an RO is credited and re-invoiced, so a prior that went through that is compared on its post-correction codes.

No live timings, and one query to watch. meta.measured is false. The self-join back onto WKROFILE by plate is the only query here whose cost is not obviously bounded by the date window — it seeks AITONE_WKROFILE_REG_REQDT, which leads with REG for exactly this shape, and that seek is why ?reg_match=exact compares the raw column on both sides. ?reg_match=loose upper-cases and trims both, finds plates keyed inconsistently, and cannot be seeked — run it once to see whether the two disagree on your data. No new index-registry entries: it rides that entry plus AITONE_WKINVREG_WORKDATE, _BR_RO, AITONE_WKMECHWK_RONUM and AITONE_WKOTHSUB_RO_INV.
KeyTypeRequiredDescription
within_daysintegeroptHow long after a job a return still counts, 1–365. Default 30. Also the eligibility window for the denominator — change it and both the numerator and what counts as a fully-elapsed prior move together.
rulestringopteither (default) = same_job_code OR return_no_charge · both = the tight definition, far fewer and far surer · no_charge = the industry reading · same_code = the loosest · any_return = every candidate pair, no test, the population for calibration. All five are scored in summary.by_rule whichever you pick.
min_gap_daysintegeroptIgnore returns inside N days of the invoice. Default 0, which keeps the same-day return — that is a comeback signal in its own right rather than noise. Must be less than within_days.
no_charge_typesstringoptWKOTHSUB TYPE codes the shop eats. Default I,W,P (Internal, Warranty, Policy).
date_fromdateoptYYYY-MM-DD on the prior's invoice date. Default: the 90 days ending yesterday on the database clock — not today, because a job invoiced this morning would contribute a whole day to the denominator having had a few hours to come back. Capped at 371 days.
date_todateoptYYYY-MM-DD, inclusive. Alone, it still means "the same 90 days, ending there".
group_bystringoptpair (default) = one row per (prior, return) with every signal · branch = per prior branch, with a rate from its own denominator · technician = per technician who worked the prior, with a rate on the same footing · job_code = per job code on the prior, counts only (comeback_pct is null — see meta.rate_note)
reg_matchstringoptexact (default) compares the raw REG column on both sides — the only form that can seek the index. loose upper-cases and trims both, catching plates keyed two ways, and is a scan. The default is exact because a plate stored two ways is a data problem worth seeing rather than one worth paying a full scan to hide.
branchstringoptComma list, on the prior branch. branch_include is the alias; branch_exclude removes.
franchisestringoptComma list, on the prior's franchise. franchise_include is the alias.
technician_codestringoptOnly priors this technician had a labour line on. mechanic_code is accepted. Narrows the denominator too, so the rate stays that technician's own.
pageintegeroptPage number
limitintegeroptRows per page. Blank = all.
json
{ "meta": {
    "grain": "one row per (prior job, return job) pair on the same registration",
    "window": { "from": "2026-06-04", "to": "2026-09-01", "days": 90,
                "basis": "the PRIOR job's invoice date (wkinvreg.work_date)" },
    "within_days": 30, "rule": "either", "no_charge_types": ["I", "W", "P"],
    "reg_match": { "mode": "exact" }, "measured": false },
  "summary": {
    // the denominator is ELIGIBLE priors — those whose 30 days have passed
    "priors": 1847, "priors_eligible": 1402, "priors_pending": 445,
    "comebacks": 41, "comeback_pct": 2.92,
    // real returns, on priors whose window is still open — never in the rate
    "in_progress": 9,
    // pairs > comebacks: one job came back twice
    "pairs": 44, "cross_branch_pairs": 3, "same_technician_pairs": 28,
    "avg_gap_days": 11.4,
    // every rule scored over the same candidates — pick from this
    "by_rule": { "any_return": 137, "same_code": 52, "no_charge": 23,
                  "either": 44, "both": 31 } },
  "data": [
    { "prior_branch": "M0101", "prior_ro": 561204,
      "reg_no": "ABC123GP", "prior_out_date": "2026-08-14 16:22:00",
      "return_branch": "M0101", "return_ro": 563991,
      "return_in_date": "2026-08-21 08:00:00", "gap_days": 7,
      "return_invoiced": true, "cross_branch": false,
      // the three signals, each measured separately
      "same_job_code": true, "return_no_charge": true,
      "same_technician": true,
      "prior_technicians": ["1042"], "prior_job_codes": ["REPAIR"] }] }

Booking Reliability

Does the book actually arrive. The no-show rate, with a real denominator, sliced the four ways somebody can act on: which advisor took the booking, which customer keeps not turning up, how far ahead it was made, and whether anybody confirmed it. Served by workshop_booking_reliability.php.

GET workshop_booking_reliability.php ?group_by= &date_from= &branch= …

What this adds, given Workshop Summary already counts no-shows. That endpoint discovered the rule, validated it on live data, and uses it to exclude these cards from every weekday average — reporting the count back as ro_type_e per branch per weekday and as a daily series, with ?zero_closed=only turning it into the no-show report at that grain. All of that stays there and none of it is duplicated here. What a weekday shape cannot answer is the four things a service manager would change something about: who booked it, who didn't come, how far ahead, and was it confirmed. The rule itself is not restated — it lives in helpers.php as zeroClosedBody() and both files call it, because two copies of a signature this specific would drift, and the drift would surface as two endpoints disagreeing about how many no-shows a branch had, both plausible and neither checkable.

A booking has three states, not two, and the rate divides by two of them. The no-show signature only forms once somebody closes the card off, so a jobcard still open is neither. no_show = the zero-closed signature, one WKINVREG row with RO_TYPE 'E' — the only positive evidence of a no-show there is. attended = it has clocking, or an invoice that is not that signature. undetermined = still open, or closed with neither work nor documents. no_show_pct = no_show ÷ (no_show + attended). Counting undetermined as attended understates every rate, counting it as a no-show overstates them, and dropping it silently would hide that a branch leaves part of its book unresolved — which is itself the finding. summary.undetermined_pct is how much of the answer is missing.

The window ends yesterday for the same reason: a booking for tomorrow has not had the chance to be either, and putting it in the denominator would drag every rate toward zero at the recent end — the end anybody looks at. Pushing date_to to today or beyond is allowed and warns, because those rows can only ever be undetermined. The booking date is the same probed ladder Workshop Summary and Service Due bind — req_datetime → expected_datetime → creation_date — because req_datetime is the day the vehicle is booked to come in, which is the only date a no-show can be counted against. GETDATE() is deliberately not a candidate: an undated RO would land on today and be counted against a day it has nothing to do with. Those are in summary.undated.

?group_by=lead_time is the grain that changes policy. Days between the jobcard being raised and the day the car was due in, banded 0 / 1 / 2-3 / 4-7 / 8-14 / 15-30 / 31+ — plus retro for a negative lead time, a card raised after the slot it carries, which is a walk-in written up retrospectively rather than a booking at all and would otherwise sit in the same-day band and make it look flawless. If the 15+ bands no-show at four times the 0–3 bands, the answer is a shorter book or a confirmation call, and that is an argument you can only have with the bands in front of you.

Confirmation values are passed through raw and grouped as found. The column is probed, not assumed — WIP's candidate list, and null when nothing binds, which costs the confirmed grain and nothing else. Nothing here decides that 'Y' means confirmed and 'C' does not: that mapping differs per install, and a wrong guess would put the no-show rate against the wrong label, which is the one outcome worse than not reporting it. The customer grain reaches the person the way Service Bookings does — REG → WKVEHFL.DRIVER → CONTACT, with the vehicle row pinned by a correlated MAX(VIN_NO) so one whole row is taken deterministically rather than two vehicles spliced into one record — and ?min_bookings=3 keeps the list to customers with enough history to mean something. A booking with no reachable customer still counts everywhere else and in the totals; summary.no_customer says how many, so the shortfall is explained rather than discovered.
KeyTypeRequiredDescription
group_bystringoptbranch (default) · advisor · customer (the repeat-offender list) · lead_time · confirmed · date · booking (one row per jobcard — the audit view, showing which of the three states each landed in and why)
date_fromdateoptYYYY-MM-DD on the booking date. Default: the 90 days ending yesterday on the database clock. Capped at 371 days.
date_todateoptYYYY-MM-DD, inclusive. Alone, it still means "the same 90 days, ending there". Reaching today or beyond warns — those bookings can only be undetermined.
min_bookingsintegeroptcustomer grain only. Default 3. Customers below it are dropped from the list, never from the totals — one customer who missed their only visit is a 100% no-show rate and not a pattern.
include_quotesflagopt1 = count quotation ROs. Off by default — a quote is not a booking, and nobody fails to turn up for one.
zero_closed_typestringoptThe wkinvreg.RO_TYPE code(s) that mark a card closed at zero. Comma list, default E. A different domain from the za_* ro_type status — run Workshop Summary?schema=1 for the census on this install before changing it.
ro_date_basisstringoptPin the WKROFILE booking-date column. Default binds req_datetime → expected_datetime → creation_date; meta.ro_date_basis reports which and whether it was a fallback.
confirmed_colstringoptPin the confirmation column. Probed as confirmed_status → confirm_status → confirmed_ind → confirmed → booking_confirmed. Null costs the confirmed grain and nothing else.
branchstringoptComma list. branch_include is the alias; branch_exclude removes.
franchisestringoptComma list. franchise_include is the alias.
sales_advisorstringoptAdvisor contact code(s), comma list.
schemaflagopt1 = the live WKROFILE column list, which booking-date column bound and whether it was a fallback, and which confirmation column bound. For the RO_TYPE census behind the no-show signature itself, run Workshop Summary?schema=1.
pageintegeroptPage number
limitintegeroptRows per page. Blank = all.
json
{ "meta": {
    "grain": "one row per lead-time band", "group_by": "lead_time",
    "window": { "from": "2026-06-04", "to": "2026-09-01", "days": 90 },
    "ro_date_basis": { "column": "REQ_DATETIME", "fallback": false },
    "confirmed_col": null,
    "zero_closed": { "ro_types": ["E"] },
    "measured": false },
  "summary": {
    "bookings": 2914, "no_show": 193, "attended": 2588,
    // never folded into either side of the rate
    "undetermined": 133,
    "no_show_pct": 6.95, "undetermined_pct": 4.56,
    "undated": 4, "no_customer": 61, "quotes": 0 },
  "data": [
    // a card raised AFTER its own slot — a walk-in, not a booking
    { "lead_band": "retro", "bookings": 311, "no_show": 2,
      "attended": 305, "undetermined": 4, "no_show_pct": 0.65 },
    { "lead_band": "0",     "bookings": 742, "no_show": 21,
      "attended": 698, "undetermined": 23, "no_show_pct": 2.92 },
    // the argument for a shorter book, or for a confirmation call
    { "lead_band": "15-30", "bookings": 288, "no_show": 48,
      "attended": 227, "undetermined": 13, "no_show_pct": 17.45 }] }

Quote Conversion

What happens to the work we quote for. Quotation ROs, what became of each one, and the conversion rate — plus the open ones, aged, with the value still sitting in them. Every other workshop feed excludes quotes and says so; nothing has ever looked at what happened to one. Served by workshop_quotes.php.

GET workshop_quotes.php ?outcomes=1 &rule= &group_by= &within_days= …
Run ?outcomes=1 first. It is not a rate — it is the census that tells you whether a rate is even measurable here. There is no accepted/declined flag on WKROFILE that this codebase has ever read, so how your DMS records a yes is genuinely unknown, and there are three plausible mechanisms. (A) The quote becomes the job — QUOTATION_IND is cleared on acceptance and the card carries on to invoice. (B) A new jobcard is raised and the quote is closed off. (C) The quote RO is invoiced directly, keeping its flag. ?outcomes=1 reports what observably became of every quote, crossed against whether a later non-quote jobcard appeared on the same vehicle, and summary.reading says in plain words which mechanism the shape points at.
Mechanism A is the one that bites, and it fails silently. If the DMS clears QUOTATION_IND when a quote is accepted, then every accepted quote has left this population before it was counted — the query selects on that flag, so it can only ever see the ones that were not accepted. Conversion would read close to zero on a shop converting perfectly well. The one mercy is that it fails in the unflattering direction, so it will at least be questioned. ?outcomes=1 flags it: a large closed_unbilled population with no later job and no invoice is the signature. Check a handful of those RO numbers in the DMS by hand before publishing any conversion figure.

The four fates, and the order they are tested in. closed_zero — exactly one WKINVREG row carrying RO_TYPE 'E'; on an ordinary booking Workshop Summary established that as a no-show, and on a quote it is the natural way to close off work that was declined. invoiced — it carries a WKINVREG row that is not that signature, so real money was billed. open — still live, and the only fate you can still act on. closed_unbilled — closed with neither work nor documents: the residue, and where mechanism A would hide. Tested in that order, and the order is the definition: a zero-closed card wins outright because it is the only positive evidence of a decline there is. The rule itself is shared with Workshop Summary and Booking Reliability through helpers.php, so the three cannot disagree.

The denominator is eligible quotes only. A quote raised yesterday has not had its within_days to be accepted in, and counting it drags conversion down by the share of the window not yet elapsed — worst at the recent end, which is the end anybody looks at. So conversion_pct divides by quotes whose whole window has already passed; conversions on a quote still inside its window come back as in_progress, real and deliberately outside the rate. Same rule Comebacks applies, and service_retention.php before it.
quoted_value will not tie to the WIP board's Quote total, and both are right. Here it is WKRODESC.VALUE summed over the RO plus the INSALPAR lines on its parts order — two keyed sums, ungated by invoice state, because a quote's value has to be readable whatever became of it. WIP values open ROs through a four-way union, nets labour and sublet back out of the WKRODESC figure (which carries both) and adds the RO charges once. Use the board for what open WIP is worth and this for what was quoted — including on the closed quotes the board cannot see. Reconciling them would mean running that union over a population the board deliberately excludes.
KeyTypeRequiredDescription
outcomesflagopt1 ⇒ the census, and no rate at all. Every quote's fate crossed against whether a later non-quote jobcard appeared on the same vehicle, with summary.by_fate, value_by_fate, closed_unbilled_without_later_job and a reading[] that names the mechanism in words. Run this before anything else — it exists so the conversion rule is chosen from evidence rather than guessed.
rulestringopteither (default) · invoiced = the quote RO itself carries a real invoice (mechanism C) · followed = a later non-quote jobcard on the same plate inside within_days (mechanism B). Pick from what ?outcomes=1 showed you.
within_daysintegeroptHow long a quote has to convert, 1–365. Default 30. Also the eligibility window for the denominator — the two move together, which is the point.
group_bystringoptbranch (default) · advisor (who quotes, and who closes) · franchise · month (the trend) · age (open quotes only, banded 0-7 / 8-14 / 15-30 / 31-60 / 61-90 / 91+, no rate — see meta.age_note) · quote (one row per quotation RO with its fate and value)
date_fromdateoptYYYY-MM-DD on the quote date. Default: the 180 days ending yesterday on the database clock — longer than the other feeds' 90, because a window barely exceeding within_days would leave almost every quote in it ineligible for the rate. Capped at 371.
date_todateoptYYYY-MM-DD, inclusive. Alone, it still means "the same 180 days, ending there".
ro_date_basisstringoptPin the quote-date column. Default creation_date → req_datetime → expected_datetime. Deliberately not the booked-in ladder the other workshop feeds bind: a quote is dated when it was raised, and Service Bookings sits quotes at creation_date for the same reason — they have no appointment slot.
zero_closed_typestringoptThe RO_TYPE code(s) meaning closed at zero. Default E. Shared with Workshop Summary and Booking Reliability; run that first endpoint's ?schema=1 for the census on this install.
min_quotesintegeroptDrop groups below this many quotes from a rollup. Default 1.
branchstringoptComma list. branch_include is the alias; branch_exclude removes.
franchisestringoptComma list. franchise_include is the alias.
sales_advisorstringoptAdvisor contact code(s), comma list.
schemaflagopt1 = the live WKROFILE column list, which date column bound — and quote_like_columns, any column whose name suggests accept / decline / convert. If one of those is a real status, reading it beats every inference in this endpoint and the file should be rewritten against it. QUOTATION_IND is not that column: it says a card is a quote, not what was decided about it.
pageintegeroptPage number
limitintegeroptRows per page. Blank = all.
json
// ?outcomes=1 — the census. Run this one first.
{ "meta": { "mode": "outcomes", "within_days": 30,
           "date_basis": "CREATION_DATE", "measured": false },
  "summary": {
    "quotes": 418,
    "by_fate": { "open": 96, "invoiced": 211,
                 "closed_zero": 78, "closed_unbilled": 33 },
    "value_by_fate": { "open": 412880.55, "invoiced": 1104233.10,
                       "closed_zero": 288104.90, "closed_unbilled": 96417.25 },
    "closed_unbilled_without_later_job": 29,
    // the mechanism, named in words rather than left to be worked out
    "reading": ["A substantial share of quotes carry a real invoice, which is MECHANISM C: the quote RO is billed directly and keeps its flag. Use ?rule=invoiced."] },
  "data": [
    { "fate": "invoiced", "has_later_job": false, "quotes": 188 },
    { "fate": "open",     "has_later_job": false, "quotes": 91 },
    { "fate": "closed_zero", "has_later_job": true, "quotes": 44 }] }

// then, with the rule chosen: ?rule=invoiced&group_by=advisor
{ "summary": { "quotes": 418, "eligible": 371, "pending": 47,
              "converted": 197, "conversion_pct": 53.10,
              // real, and deliberately outside the rate
              "in_progress": 14,
              "quoted_value": 1901635.80, "open_value": 412880.55 },
  "data": [
    { "advisor_code": "1042", "advisor": "JANE SMITH",
      "quotes": 63, "eligible": 58, "converted": 19,
      "conversion_pct": 32.76, "quoted_value": 288540.00 }] }

Service Due

Which vehicles are about to need a service. A call list: one row per registration due inside the horizon — by the date the DMS itself holds where there is one, and by projected odometer movement where there is not. Served by workshop_service_due.php.

GET workshop_service_due.php ?reg= &branch= &next_service_col= …
Route to it as type=workshop_service_due, and check that proxy.php actually forwards the parameters below. A fixed pass-through list will swallow next_service_col and odometer_col silently, and the endpoint will answer with its defaults as though nothing were wrong — which is the likeliest reason a parameter appears to do nothing.

Two sources, and the stated one wins. A vehicle reaches this list through either route: WKVEHFL.NEXT_SERV_DATE holds a real date inside the window, or the mileage projection says it is about to hit its own typical service distance. Where the DMS states a date, that date is used as entered — it is a fact somebody put in the system, and this endpoint does not guess over a fact. Where both have something to say, the sooner date wins: a stated date is never pushed later by an estimate, only overtaken when the vehicle will reach its interval first — the one thing a date entered months ago cannot know. Every row carries due_date, due_basis and both source dates, so the disagreement stays visible rather than being averaged away.

This changed on 2026-08-27, and it changed who is on the list. The projection used to be the only source and dms_next_service_date rode along untouched, to be compared by eye. That was defensible while the question was "what does usage predict", but this is a call list, and on a call list an inference has no business outranking a stated date. It also left a whole class of vehicle permanently invisible — fewer than three jobcards, or due annually on time rather than on distance — no matter how plainly the DMS said they were due. Those vehicles now appear, and are counted in summary.not_estimable_rescued. Expect the list to be longer than before.
A stated date more than 90 days old is ignored, not honoured. This is the one place a stated date is not taken at face value, and it is deliberate: a NEXT_SERV_DATE two years past is almost never a vehicle 730 days overdue — it is a vehicle that stopped coming and a field nobody updated again. Honoured literally, it would beat every fresh projection under soonest-wins and fill the list with vehicles last seen years ago. Ignored, the vehicle still reaches this report on its mileage, and still reaches summary.lapsed if that is what it is.

The mileage estimate, in four lines — used where no date is stated. Every RO for a registration carries an odometer reading, so the oldest and newest give a usage rate — km_per_day = (last_odo − first_odo) ÷ (last_date − first_date) — and the number of jobcards between them gives the average odometer gap between jobcards, avg_gap = (last_odo − first_odo) ÷ (jobcards − 1). Project the odometer forward from the last jobcard at that rate and the vehicle is due when it reaches last_odo + avg_gap. At least three jobcards are required — two readings give one gap, and one gap is not an average. If that lands inside the next three weeks, the vehicle is on the list. The scan is one GROUP BY over WKROFILE — no joins, six numbers a registration; contact detail is a second keyed read over the page's regs only, so it is bounded by ?limit and not by the scan.

Every RO with a reading counts as a visit — not only services. A tyre fitment counts. That is what keeps this a single-table scan, and the trade is explicit: the usage rate gets more datapoints and is better for it, while the interval is biased short, because visits are more frequent than services. Expect vehicles to read as due earlier than they are, and check interval_basis on every row. An average gap outside 5 000–50 000 km is clamped to the nearest bound and marked clamped — two visits 300 km apart would otherwise say "due every 300 km" and top the list forever.
Did the vehicle change hands? driver.changed says so — and nothing else acts on it. The scan looks back 36 months and counts how many distinct driver codes the registration was raised under. More than one means it changed owner. Such a vehicle stays on the list with its full history: the odometer does not reset when a car is sold, and re-basing the estimate on post-sale visits alone would push most recently-sold vehicles below the three-reading minimum and silently drop the new customer you most want to call. Contact detail always resolves to the current driver, so the previous owner is never phoned. Compare driver.visits_under_current_driver with visits to see how much of the history belongs to the person on the row, and driver.current_since for when the handover happened. null means unknown, not unchanged — you get it on a DMS-dated row (never scanned) and on any install where no driver column binds; summary.driver_changed.unknown equalling the row count is how you spot the latter.

The mileage half is distance-only. A low-mileage vehicle needing an annual service on time cannot be predicted by it — on distance it is not due for years. Such a vehicle appears here only if NEXT_SERV_DATE says so, which is a large part of why that field is now preferred. On an install where it is not maintained, those vehicles stay invisible and summary.due_by is the number that reveals it: all mileage, no stated dates, means the field is empty. Two further assumptions worth arguing with: the highest reading is assumed to be the most recent (a mis-keyed cluster breaks that; rates over 500 km/day are treated as odometer faults, dropped and counted), and the vehicle is assumed to come back to us — serviced elsewhere last time means a stale last_odo and a vehicle that reads as due later than it is.

Run ?schema=1 first. Both sources are probed per install and both are reported. The odometer column on WKROFILE is read by no other endpoint here, so its name (odometer → odo_reading → odometer_in) is unverified; the next-service column on WKVEHFL binds next_serv_date → next_service_date. Check mileage_projection_ready and dms_dates_ready, and pin whatever bound wrong with ?odometer_col= or ?next_service_col=. Either source alone is enough to run — a missing one is a narrower answer, reported as meta.sources.mode (both / dms_dates_only / mileage_only), not a failure. The 501 is now only for neither, where there is no date to give from either direction. WKVEHFL.LATEST_ODO is still not a substitute for the odometer — one current reading gives no rate.
Two exclusions keep the list a call list. A registration with an RO dated today or later, or an RO in the last 90 days still open, is already in the diary or already in a bay — suppressed, and counted as summary.already_booked. Checked across all branches even under ?branch=, because a booking at the group's other branch is still a booking; quotes do not count. Separately, a vehicle due more than 90 days ago is counted as summary.lapsed and not listed — it is not due in the next three weeks, it stopped coming, and left unbounded those accumulate until they outnumber the genuine ones. Neither is deleted: both counts are on every response, so a lapsed-customer campaign can ask for them deliberately.

?branch= changes the maths, not just the list. Rows are grouped by REG alone — never by (branch, reg), which would split one vehicle's history into two half-histories and give two wrong answers instead of one right one. So a branch filter restricts which visits feed each estimate, and a vehicle serviced at two branches gets a partial history and a different rate. For a DMS-dated vehicle it restricts membership instead: the vehicle qualifies if it was seen at that branch inside the lookback window. branch on a row is where the vehicle was seen last. confidence follows the number that drove the row: stated when the DMS date did — which sorts above high, the practical claim being "call these first" — and otherwise high / medium / low grading the strength of the mileage evidence (high = 5+ readings over 180+ days with an unclamped interval). Vehicles the model could not estimate are counted in summary.not_estimable_detail — bad_dates, no_usable_span, rate_implausible, of which only the last is worth chasing — but only when the DMS had nothing to say about them either; the ones that made the list anyway on a stated date are not_estimable_rescued, which is the opposite of a drop.

Run ensure_indexes.php before the first real call. Three entries exist for this report — AITONE_WKROFILE_REQDT_REG for the scan, AITONE_WKROFILE_REG_REQDT for the last-branch lookup, and AITONE_WKVEHFL_NEXTSERV (next_serv_date, reg) for the DMS-date range added on 2026-08-27. The first two are the reverse of each other and not interchangeable, since a composite index only serves seeks on a leading prefix — and for the same reason the third is not served by AITONE_WKVEHFL_REG, which leads on reg: that query has no reg to seek on, it is asking which registrations qualify. Leading on the date makes it a seek and reg second makes it covering, so the wide vehicle-master rows are never touched. This is also the only feed here that ranges over 36 months of WKROFILE, so the first call should not be the one that discovers there is no index behind it.

?reg= — one vehicle, and its mileage today. Added 2026-09-16 for the Service Connect customer app, which offers an estimated odometer reading when a customer books a service. The same model runs over the same 36 months restricted to one registration, and the row comes back whether or not the vehicle is due: the horizon, lapsed and already-booked tests annotate it — listed, not_listed_reason (not_due · lapsed · already_booked · no_due_date), already_booked — instead of dropping it. A registration with no usable history still returns one row, with nulls and estimate_unavailable_reason. The registration is upper-cased with whitespace removed and matched exactly.

odometer_estimate is the one figure to show. basis: projection — the last reading moved forward at the vehicle's own km_per_day, needing the model's usual three jobcards, and re-based on WKVEHFL.LATEST_ODO when that reading is newer than the last jobcard (projected_from says which). Otherwise last_jobcard_reading or vehicle_latest_odo: the newest reading on file, not moved forward, with confidence: low — one or two readings give no rate worth projecting on. It never reports less than the highest reading on file. latest_jobcard_reading and vehicle.odometer_date ride alongside so the figure can be checked. The model's constants are unchanged: ?reg= changes which vehicle, not how it is judged.
KeyTypeRequiredDescription
regstringoptOne vehicle, returned whether or not it is due, with listed / not_listed_reason and an odometer_estimate block — its mileage today. Letters, digits and dashes, up to 20; whitespace is removed and it is upper-cased. Exact match.
branchstringoptComma list. branch_include is the alias; branch_exclude removes. Changes the estimate, not just the list — it restricts which jobcards feed each vehicle's history.
next_service_colstringoptPin the WKVEHFL next-service-date column instead of probing next_serv_date → next_service_date. This is the authoritative source: where it holds a date inside the window, that date drives the row and no mileage guess is used. Get the real name from ?schema=1 (service_date_like_columns).
odometer_colstringoptPin the WKROFILE odometer column instead of probing for it. Get the real name from ?schema=1. Pin this and ro_date_basis and the catalog is never read at all.
driver_colstringoptPin the per-visit driver / contact code on WKROFILE, used to detect that a vehicle changed hands. Probes driver → contact_code → customer_code → cust_code → driver_code. No other endpoint in this API reads a driver off WKROFILE, so the spelling is unverified — run ?schema=1 and pick from driver_like_columns. When nothing binds the due list is unchanged; only the driver block goes null.
ro_date_basisstringoptPin the RO date column. Default binds req_datetime → expected_datetime → creation_date.
schemaflagopt1 reports which columns bound for both date sources and for the driver code, plus odometer_like_columns, service_date_like_columns and driver_like_columns — the shortlists to pick from when nothing bound. Run this first.
pageintegeroptDefault 1.
limitintegeroptBlank = all rows. Contact detail is fetched for the page only, so a limit bounds that second query too.
json
{ "meta": { "endpoint": "workshop_service_due",
    "today": "2026-08-27", "due_by": "2026-09-17",
    // fixed in the file, reported so a saved response says what made it
    "model": { "min_jobcards": 3, "horizon_days": 21, "lookback_months": 24 },
    "odometer_basis": { "column": "ODOMETER", "source": "probed SYSCOLUMN" },
    // which sources this run actually had — mode: both | dms_dates_only | mileage_only
    "sources": { "mode": "both",
      "dms_next_service_date": { "available": true, "table": "WKVEHFL",
        "column": "NEXT_SERV_DATE",
        "window": { "from": "2026-05-29", "to_excl": "2026-09-18" } },
      "mileage_projection": { "available": true, "table": "WKROFILE" } } },
  "summary": { "regs_scanned": 4182, "dms_dates_in_window": 288,
    "candidates": 4310, "due": 109, "overdue": 17,
    "not_due": 4034,
    // which source decided each listed row. all mileage, no stated dates,
    // means NEXT_SERV_DATE is not maintained on this install — worth knowing
    // before anyone trusts the list
    "due_by": { "dms_next_service_date": 44, "mileage_projection": 61,
      "both_agree": 4 },
    // suppressed, not deleted — both are numbers somebody may want
    "already_booked": 31, "lapsed": 612, "not_estimable": 62,
    // too little history vs a suspect odometer — different problems
    "not_estimable_detail": { "bad_dates": 0, "no_usable_span": 57, "rate_implausible": 5 },
    // could NOT be estimated, but listed anyway on a stated date — not a drop
    "not_estimable_rescued": 23 },
  "data": [{
    "reg": "CA123456", "branch": "M0101",
    // THE date to act on, and where it came from
    "due_date": "2026-09-10", "due_basis": "mileage_projection",
    // jobcards = visits WITH a reading (what the estimate uses); visits = all of them
    "jobcards": 5, "visits": 7, "last_visit_date": "2026-08-14",
    // changed=true means >1 driver code across the 36-month history — it changed hands.
    // null = UNKNOWN (no driver column bound, or no scan row), never "unchanged".
    // The contact below is always the CURRENT driver, so the old owner is never called.
    "driver": { "changed": true, "code_count": 2,
                "current_code": "40118", "latest_ro_code": "40118",
                "current_since": "2025-11-03",
                "visits_under_current_driver": 2,
                "matches_vehicle_master": true },
    "first_reading": { "date": "2025-08-01", "odometer": 40000 },
    "last_reading":  { "date": "2026-08-01", "odometer": 100000 },
    "km_per_day": 164.4, "km_per_month": 5004,
    // mean km between JOBCARDS, not between services — biased short
    "avg_km_between_jobcards": 15000,
    "interval_km": 15000, "interval_basis": "observed",
    "projected_odometer_today": 104274, "due_at_odometer": 115000,
    "km_remaining": 2301, "days_to_due": 14,
    // both source dates stay on the row. here the projection landed 4 days
    // sooner than the stated date, so it drove due_date — soonest wins
    "estimated_due_date": "2026-09-10", "dms_next_service_date": "2026-09-14",
    "estimate_available": true, "estimate_unavailable_reason": null,
    "status": "due_soon", "confidence": "high",
    "vehicle": { "make": "TOYOTA", "model_name": "HILUX 2.8 GD-6",
      "dms_next_service_date": "2026-09-14" },
    "customer": { "name": "John Dlamini", "mobile": "0821234567",
      "privacy_service": "Y", "privacy_marketing": "N" } },
  {
    // a DMS-dated vehicle the mileage model could never see: only 2 jobcards.
    // every mileage field is null rather than missing, so the shape never varies
    "reg": "CJ778899", "branch": "M0102",
    "due_date": "2026-09-02", "due_basis": "dms_next_service_date",
    "days_to_due": 6, "status": "due_soon", "confidence": "stated",
    "dms_next_service_date": "2026-09-02", "estimated_due_date": null,
    "estimate_available": false,
    "estimate_unavailable_reason": "insufficient_history",
    "jobcards": null, "km_per_day": null, "interval_km": null }] }

{ // GET ?reg=WZM584MP — one vehicle, not due, with its mileage today
  "meta": { "endpoint": "workshop_service_due", "reg": "WZM584MP", "today": "2026-09-16", /* … */ },
  "data": [
    { "reg": "WZM584MP",
      "due_date": "2027-01-20", "due_basis": "mileage_projection",
      "days_to_due": 126, "status": "not_due", "confidence": "high",
      // how the call list would treat it — annotated, never dropped
      "listed": false, "not_listed_reason": "not_due", "already_booked": false,
      "jobcards": 6, "km_per_day": 48.2, "km_per_month": 1467,
      "last_reading": { "date": "2026-05-04", "odometer": 112400 },
      "latest_jobcard_reading": { "date": "2026-05-04", "odometer": 112400 },
      "vehicle": { "make": "MAHINDRA", "model_name": "SCORPIO 2.2 CRDe mHAWK P/U S/C",
        "latest_odo": 112400, "odometer_date": "2026-05-04", /* … */ },
      // THE figure to show — never below the highest reading on file
      "odometer_estimate": {
        "odometer": 118664, "as_of": "2026-09-16", "basis": "projection",
        "projected_from": { "source": "last_jobcard_reading", "odometer": 112400, "date": "2026-05-04", "days_since": 130 },
        "km_per_day": 48.2, "km_per_month": 1467, "confidence": "high" },
      "on_vehicle_master": true }] }

Service Retention

Of the vehicles we sold, how many come back to us for a service — in their first year, their second, and so on. The cohort is sold units on VhStock; the return is a workshop invoice against a repair order on the same vehicle. One row per anniversary window, with a per-cohort matrix underneath and an optional per-vehicle listing. This is the aftersales half of a vehicle sale: the sales department's figure is the unit out of the door, and this is whether the customer was still yours twelve months later.

The rate divides by the population whose window has actually elapsed — the pattern Comebacks and Quote Conversion follow, and this is the endpoint that set it. A car sold last month cannot yet have failed to come back for its first service, so it is not in the year-1 denominator. Counting the not-yet-elapsed drags every rate down worst at the recent end, which is the end anybody looks at, and it does it invisibly.

GET service_retention.php ?by= &max_years= &as_of= &detail=1 …

The windows are measured from each unit's own sale date, not from a calendar year: year 1 is [sale, sale+1yr), year 2 is [sale+1yr, sale+2yr), and a unit is retained in year N if it has at least one workshop invoice dated inside that window. Every unit therefore has its own clock, which is the only way a cohort matrix means anything — a car sold in November and a car sold in March are both judged on their own twelve months rather than against 31 December.

Three numbers per year, and they are not interchangeable. eligible is the units whose year-N window has fully elapsed as at as_of — the denominator. retained is the eligible units serviced inside that window. serviced_in_progress is units serviced in a window that has not elapsed yet: real early signal, reported so it is not lost, and deliberately kept out of the rate so it can never push one above 100%. retention_pct is null rather than 0 when nothing is eligible — no cohort has aged that far yet, which is a different fact from "nobody came back" and must not render as a zero on a chart. cumulative_pct answers the softer question, have they come back to us at all yet, over years 1..N.
The linkage is two hops and the second one is the fragile half. VhStock.VIN_NO → WKVEHFL.VIN_NO is by VIN and is as solid as a VIN is. WKVEHFL.REG → WKROFILE.reg is by registration, and a registration is not permanent: it changes on a sale between provinces, on a personalised plate, on a re-registration. Where it has changed, the workshop history filed under the old plate will not match, and this endpoint will read that vehicle as not retained — so the figure is a floor, not a point estimate, and it is understated by exactly the population whose plates moved. Nothing in this codebase has measured how large that population is on a live install, and meta.linkage carries the chain on every response so the assumption is visible rather than buried here. module_type = 'W' is load-bearing on the invoice probe for the usual reason: without it a colliding document number fans the join out across the parts and vehicle ledgers.

The cohort is sold units that have both a VIN and a sale date — vehicleSoldClause(), then SALESDATE IS NOT NULL and a non-empty VIN_NO. A unit with no VIN cannot be linked to a workshop record at all, so including it would put a guaranteed non-returner in the denominator and understate every rate. meta.total_vehicles_sold is the count after that filter, so it is the honest denominator and not the showroom's sales figure; expect the two to differ and reconcile them on VIN capture rather than assuming a fault here.

Paging never moves a rate. ?detail=1 pages over the cohort in memory, while every aggregate — retention_by_year, cohorts, all of meta — is computed over the full cohort regardless of ?page= and ?limit=. Page 4 of a detail listing carries the same headline percentages as page 1. ?as_of= likewise never reaches SQL: it is used only for the PHP eligibility arithmetic, so back-dating the report re-cuts the denominators without changing the query or its plan.

Worth checking on your install before quoting the number: the invoice probe accepts invo_type IN ('I','C'), so a credit note counts as a service visit the same way an invoice does. A job that was invoiced and then fully credited will therefore still read as a return. That is defensible — the customer did come back — but it is not the only reading, and if your reversal volume is material the rate is generous by that amount. service_invoice_count is a COUNT(DISTINCT document_no), so it is visits rather than lines. Unlike the newer inference modes this endpoint carries no meta.measured: its arithmetic is structural, out of dates, rather than a rule inferred about what a DMS column means.
KeyTypeRequiredDescription
date_fromdateoptYYYY-MM-DD. Bounds the sale cohort, not the service activity — this is which units are being followed, never when they came back.
date_todateoptYYYY-MM-DD, inclusive to the end of that day. Narrowing the cohort to recent sales is the quickest way to make every rate null: those units have no elapsed window yet.
makestringoptExact match on VhStock.MAKE, case-insensitive.
modelstringoptPartial match, unlike make — model strings carry trim and derivative, so an exact match rarely finds anything.
franchisestringoptExact VhStock.fran code.
vinstringoptPartial VIN. Useful with ?detail=1 to trace one unit's whole service history against its sale.
year_fromintegeroptYEAR_MANUF >=. The model year of the vehicle, not the year it sold.
year_tointegeroptYEAR_MANUF <=.
max_yearsintegeropt5 (default), clamped to 1–10 rather than refused. How many anniversary windows to report. Asking for more than your oldest cohort can fill costs nothing — the extra years simply come back eligible: 0 with a null rate.
as_ofdateoptReporting date, default today. Never reaches SQL — used only for the eligibility arithmetic, so it re-cuts the denominators without touching the query. Back-date it to reproduce a figure quoted last quarter.
bystringoptsale_year (default) | make | franchise | none. The cohort matrix under the headline curve. none returns the overall curve alone. An unrecognised value falls back to sale_year silently, so check meta.group_by rather than assuming a typo was refused. Blank keys group as (blank), an unparseable sale year as (unknown), rather than being dropped.
detailflagopt1 = add the per-vehicle listing: each sold unit, whether it returned, how many days to its first service, and which anniversary windows it was serviced in.
pageintegeropt1. Pages the detail listing only; the aggregates are always over the full cohort.
limitintegeroptDetail rows per page; blank = all.
debugflagopt1 = per-query timing / size in a _debug block.
json
{ "meta": { "as_of": "2026-09-08", "max_years": 5,
    "group_by": "sale_year",
    "total_vehicles_sold": 1842,
    "vehicles_with_service": 1109,
    "vehicles_never_serviced": 733,
    "overall_service_rate_pct": 60.2,
    // the chain, on every response — the REG hop is the fragile one
    "linkage": "VhStock.VIN_NO → WKVEHFL.REG → WKROFILE.RO_NUMBER → Invoice(module_type=W)" },
  "retention_by_year": [
    { "year": 1, "window": "0–12 months",
      // denominator = windows that have FULLY elapsed as at as_of
      "eligible": 1504, "retained": 1022, "retention_pct": 68.0,
      // serviced inside a window that has NOT elapsed — never in the rate
      "serviced_in_progress": 87,
      "cumulative_eligible": 1504, "cumulative_retained": 1022,
      "cumulative_pct": 68.0 },
    { "year": 2, "window": "12–24 months",
      "eligible": 1131, "retained": 602, "retention_pct": 53.2,
      "serviced_in_progress": 140,
      "cumulative_pct": 74.1 },
    // no cohort has aged this far yet: null, NOT zero
    { "year": 5, "window": "48–60 months",
      "eligible": 0, "retained": 0, "retention_pct": null,
      "serviced_in_progress": 0, "cumulative_pct": null }],
  "cohorts": [
    { "cohort": "2025", "vehicles_sold": 412,
      "retention_by_year": [ /* same nine keys per year */ ] }],
  "detail": { "meta": { "total": 1842, "page": 1 },
    "data": [{ "vin_no": "AHTKB3CD10488xxxx", "reg_no": "KZ88ZNGP",
      "make": "TOYOTA", "sale_date": "2024-03-14 00:00:00",
      "years_since_sale": 2, "service_invoice_count": 3,
      "first_service_days": 341,
      "serviced_in_years": [1, 2], "returned": true }] } }

Service Time Benchmark

How many hours a service actually takes, by make, model, variant, build year and service interval — in kilometres for vehicles and HOURS for tractors. Clocked hours off WKMECHWK, the job and its free text off WKOTHSUB, the registration off WkRoFile and the vehicle off WKVEHFL. Ask it "a 90 000 km service on this variant — what should I book for it?" and it answers from what the workshop has actually done, not from an estimate somebody typed.

Nothing in the DMS holds this figure. WkCodeFl.EST_HOURS is per job code, so every service on every vehicle shares one number, and WKOTHSUB.est_hours is whatever the advisor keyed on the card. Where either exists this endpoint reports it beside the actual and gives you variance_vs_est_pct — the number that says whether the shop's own booking figure is right.

SERVICE and REPAIR lines are both read, because on this install the interval is sometimes typed on the repair line rather than the service one. Reading them is the easy half; deciding which of them is a service is what keeps the average honest, and the two warning panels below are the whole of that decision.

A schedule is identified by make · model · variant · build_year, and that is the default grouping. build_year is YEAR(WKVEHFL.BUILD_DATE) — the day and time on that timestamp are noise. A facelift can move the times and a 2019 is not a 2024, so the year earns its place; but five dimensions fragment hard, so expect a large groups_insufficient and drop the year first when it bites.

GET workshop_service_benchmark.php ?group_by= &make= &variant= &date_from= &unmatched=1 …
The grain is one SERVICE, not one clocking line — and that is the whole arithmetic. WKMECHWK holds one row per SEQ per technician, so a four-hour service split between two people is two rows of two hours. Averaged at line grain that service reads as two hours and the benchmark comes out at half, plausibly and invisibly, worst on exactly the big jobs a benchmark exists to size. So WKMECHWK is summed to (ro_branch, ro_number, job_code, job_type) in a derived table before anything is joined to it. technicians_per_job rides on every row so the split is visible.

The join to WKOTHSUB has a column-name trap on its fourth leg — WKMECHWK spells it JOB_TYPE and WKOTHSUB spells it TYPE. Every workshop feed here makes the same four-column join and getting it wrong drops rows silently. WKVEHFL can legitimately hold more than one row per registration, so it is pinned to one by the same correlated MAX(VIN_NO) Service Bookings uses — minus its DRIVER IS NOT NULL leg, because that feed wants the vehicle's owner and this one wants its make, and requiring a linked driver would quietly drop every fleet unit that has none.

The interval is INFERRED from free text. WKOTHSUB.DETAIL_LINE is VARCHAR(4000) typed by a service advisor, and the real values on this install look like Work Requested:60 000KM SERVICE , CHECK LEFT MIRROR NOT WORKING, 1 90.000KM SERVICE, .40.000KM SERVICE and Work Requested:SERVICE. So the thousands separator is a full stop as often as a space (90.000 is ninety thousand, not ninety), a leading 1  or . is a list bullet rather than part of the number, and plenty of lines carry no interval and never will. The rule that makes it work: a group separator must be followed by EXACTLY three digits. That single constraint tells 60 000KM (a real sixty thousand) from 1 90.000KM (bullet 1, then ninety thousand) — in the second,  90 is a two-digit group, so the space is not a separator. Without it the line reads as 190 000 and the benchmark grows an interval that does not exist.
TRACTORS ARE METERED IN HOURS, and the same column carries both units in two languages. (2026-09-10.) Real lines: 1500 UUR DIENS - REIS N AKLIENT…, 1000H DIENS, 400HRS DIENS, DIENS 2500H, DOEN VAN N 7000 UUR DIENS. REIS NA PLAAS…. Three things follow, all load-bearing. First, the word for "service" is DIENS — Afrikaans, and on the machinery side it is the only word used. Every place this endpoint looks for SERVICE now looks for DIENS too: the two unitless rules, the nearest-to-service tie-break, and the trust rule that decides whether a repair line's number is believed. Missing it would not have failed loudly — it would have quietly excluded the entire machinery half of the workshop while the vehicle half looked perfectly healthy. ?rules=1 lists the words matched (SERVICE, SERVIS, DIENS, DIENSTE).
Second, a reading carries its unit and the unit is part of every key. service_value + service_unit is the pair; service_interval ("15000 km" / "1000 h") is the label, and it is what the default group_by uses — a key built on the bare number would merge a tractor's 1000 hours with a bakkie's 1000 km into one row whose average means nothing and whose name gives no hint anything went wrong. service_km and service_hours are the same number split by unit, each null on the other kind of job, so ?service_km=1000 can never return a machine. The inferred step is keyed by unit too — automatically, not by the caller's choice — so a make · variant covering both comes back as two rows, "every 500 h" and "every 15000 km". That is not a duplicate; it is two schedules. Third, neither unit outranks the other — proximity decides. Rules carry a tier and the pick is the lowest tier that produced anything, not the first rule in the list. km_suffix and h_suffix share tier 1, so on the real line RY UIT NA PLAAS (45 KM)UITVOER VAN 750 UUR DIENS the 750 hours wins because it sits against the DIENS while the 45 is thirty characters away. Rule order would have benchmarked the technician's drive to the farm as a service. Each unit also has its own band — km 1 000…999 999, hours 50…50 000, because one band wide enough for both is no band at all — and the km floor rejects that 45 independently. One honest gap: WKVEHFL.SERV_CYC_KM is kilometres by name, so the independent cross-check exists for vehicles and not for machines; agrees_with_declared is null on every hours group and held out of checkable_against_dms rather than counted as agreement.

Five rules across two units, and the set is a parameter. km_suffix takes a number immediately before KM / KMS / K.M. / K/M and h_suffix one before H / HR / HRS / HOUR / HOURS / UUR / URE — both tier 1, so neither unit outranks the other. k_suffix (tier 2) reads 90K and multiplies. service_suffix and service_prefix (tier 3) read a bare number beside a word for service, and treat it as kilometres, because a machine measured in hours nearly always has the H written down. ?rule_set=km (default) runs tiers 1–2; strict is tier 1 alone and all adds the unitless pair, which is off by default and carries a year guard rejecting 1900–2100 outright. ?units=km|hours|both narrows further — a dealer with no machines can switch the hours rule off outright. When several candidates match, the lowest tier that produced anything wins, and within it the candidate nearest a word for service, falling back to leftmost: that is what reads ODO 123456KM, DO 90 000KM SERVICE as the 90 000 rather than the odometer, and (45 KM)UITVOER VAN 750 UUR DIENS as the 750 hours rather than the drive.

REPAIR lines are read too, because the interval is sometimes typed on them. (2026-09-10.) The gate is SERVICE,REPAIR. That widening loses nothing and risks a great deal, so three rules sort it out. First, a non-service line must say SERVICE before its number is believed. On a SERVICE line the job code has already said what the line is for, so 1 90.000KM needs no corroboration; on a repair line ODO 240000KM REPLACE CLUTCH is an odometer, and believed it would not merely become a bad observation — it would donate 240 000 to every other line on the card. Untrusted readings are dropped, counted as extraction.km_without_service_word, and left on the row as service_km_untrusted. Second, the card carries the interval, not the line: a job with no reading of its own inherits the one trusted interval stated elsewhere on the same repair order — which is what finally gives the Work Requested:SERVICE line an interval. Two different intervals on one card is a conflict, counted and sampled, never guessed at. interval_source on every row is own or ro.
Third — and this is the one that decides whether the benchmark is right — a repair line becomes an OBSERVATION only when it is standing in for a service line that was never raised. Take one card:
SERVICE  "Work Requested:SERVICE"  3.5 h
REPAIR   "90.000KM SERVICE, ATTEND TO REAR SPRING BUSHES"  1.0 h
Count both and one car yields two observations at 90 000 km, the 1.0 h one dragging that average down by nearly half — and both rows look entirely ordinary. Count neither and the card whose only line is a repair reading 60 000KM SERVICE never appears at all, which is the case that started this. ?repair_rule=standin (default) reads them apart by asking what else is on the card: a Repair job is an observation only if it states an interval in its own text and the card has no Service-class job. On the card above the service line is the observation, inherits 90 000 from the repair line, and the repair hours are correctly left out — the car counts once, at the right interval, with the right hours. all counts every matched job (inflating, but correct on an install that raises one job per card); none is the old behaviour. Sublet and Other are never observations. Read summary.by_job_class and summary.by_interval_source before quoting a figure — they say how much of the answer came off a repair line and how much rests on an inherited interval.
?intervals=1 — the service schedule, inferred from the milestones. Once the readings are readable the schedule falls out of them: a variant seen at 15 000, 30 000 and 45 000 is serviced every 15 000 km. That table is one row per make and variant, and the step also rides on the benchmark rows as interval_km with service_number beside it — so the 90 000 km row on a 15 000 km variant says it is service 6, which is what turns a list of intervals into a schedule. It is a second inference stacked on the first, so it is scored rather than computed, in the shape Comebacks uses: candidates proposed from the data (the GCD, every consecutive difference, the smallest milestone — nothing defaulted from a list of intervals somebody thinks dealers use), each scored on the share of jobs whose reading is an exact multiple, and every score returned in by_candidate under ?candidates=1. Weighting by jobs rather than by milestones is what stops one mistyped 20 000 among forty real services dragging the answer to 5 000, which is exactly what a bare GCD does.
The pick is the LARGEST candidate above the threshold, not the best-fitting — and that is the whole trick. Fit only ever improves as the step shrinks, because any divisor of a working step divides everything that step divides: 5 000 scores at least as well as 15 000 on every dataset that has ever existed. "Best fit" would therefore return the smallest candidate every single time. The question is not which step fits but which is the largest that still explains the work, and ?interval_fit= (default 80%) is where that line sits. One case a single step genuinely cannot describe is reported rather than papered over: a variant with a running-in service turns up at 5 000, 20 000, 35 000 — every gap is 15 000, but the largest step dividing all three is 5 000, and a reader seeing only that concludes the car is serviced three times as often as it is. Where every consecutive gap is identical and larger than the winning step, common_difference_km carries the gap and reading says the schedule in words.
And it checks itself against a column it does not read. WKVEHFL.SERV_CYC_KM is the interval the vehicle file declares. It is deliberately not an input: inferring from evidence and then agreeing with an independent column is worth far more than reading that column would be, because the agreement validates the free-text extraction, the trust rule and this inference all at once. agrees_with_declared is null where the install leaves the column empty — held out of both counts rather than scored as a disagreement — and summary.checkable_against_dms is that honest denominator. A false on a variant with hundreds of jobs is the loudest signal this endpoint can produce.
The rule measures itself, and two modes exist to tune it. meta.extraction rides on every response: jobs scanned, how many carried any text, how many yielded a reading, coverage_pct, which rule fired how often, what was rejected out of band, and a warning when the benchmark is describing a minority of the work. ?unmatched=1 is the mode to run first on a new install — the text that produced nothing, digits replaced by # so fifty jobs reading WORK REQUESTED:MAJOR SERVICE collapse to one line with a count of fifty. If the top entry is a spelling the rules do not know, that is a two-minute fix rather than a benchmark that quietly omits a third of the shop. ?extract_test=1&text=… runs the extractor over a string you paste, against no database at all, and shows every candidate it found, every one it rejected and why, and which one it took.
Jobs with no hours are EXCLUDED, and the two kinds of "no hours" are counted apart. A job whose hours column is NULL on every line is dropped unconditionally — ?include_zero_hours=1 does not bring it back — because a NULL is the absence of a measurement, and averaging an absence as though it were zero hours of work is not a defensible reading of anything. A job whose clocking sums to zero is dropped too, but that one is a real measurement and the flag does return it. They are separate counters (extraction.jobs_with_no_hours_recorded and extraction.zero_hour_jobs) because they mean different things about the install: many NULLs is a clocking process that is not being completed, many zeroes is admin being raised as job cards — two problems with two different owners. Note the grain — the test is on the job's summed hours, so a service where one of two technicians clocked nothing is still a real four-hour service and stays in. Negative-hour jobs (reversals outweighing clocking) go the same way. ?outlier_hours= is off and stays off: the hour count above which a "service" is a job that sat open for a week is a local convention, not data, and this API does not default numbers the DMS does not hold — the median, p25 and p75 ride on every row precisely so the outliers can be seen rather than guessed at.
n is part of the answer. "The average 90 000 km service on this variant takes 6.4 hours" computed from one job is the single most quotable wrong number this endpoint could produce — and it would be quoted, because it looks exactly like the ones computed from forty. So a group below ?min_jobs= (default 3) carries no average: every statistical field is null and sufficient is false — and it is not returned. (Changed 2026-09-10.) Emitting it was defensible in principle, since knowing a combination exists but is unmeasured answers "why is this variant missing", but the ratio is wrong: over a year of a multi-franchise dealer, make × variant × interval throws off far more one-off combinations than measured ones, so the payload comes back mostly nulls and every consumer writes the same filter to find the benchmark they asked for. Nothing is hidden — summary.groups is every group, groups_returned how many carry an answer, groups_insufficient the difference and services_in_insufficient_groups the services inside them. ?show_insufficient=1 returns them exactly as before, and sufficient stays on every row either way. ?min_jobs=1 is the other route and a different request: it makes them sufficient rather than visible, and gives them real averages.

The default window is a year, on the database clock — where Technician Work defaults to a fortnight. That feed asks what somebody did last week; this one needs n, and a 90 000 km service on one variant at one branch may happen twice a month. The date is the clocking date, MIN across the job's lines: DATE_CLOCKED_IN when present, else START_TIME, the same order and the same reasoning as Technician Work. Reversals net by default, the same four modes. No new index is needed — AITONE_WKMECHWK_CLOCKED, AITONE_WKMECHWK_START, AITONE_WKMECHWK_RO_JOB, AITONE_WKOTHSUB_RO_INV, AITONE_WKROFILE_BR_RO and AITONE_WKVEHFL_REG already serve every leg of it.

Not yet run against a database. meta.measured is false on every response. Run ?schema=1 first — it reports the live columns for all four tables and what bound to what — then ?unmatched=1 to see what the extractor cannot read, and only then trust a number. detail is the one binding this endpoint cannot work without; if it comes back null there, no interval can be read from anything.
KeyTypeRequiredDescription
group_bystringoptComma list from make, model, model_name, variant, build_year, service_km, branch, franchise, job_type, job_code, job_class, interval_source; year is accepted for build_year. Default make,model,variant,build_year,service_km — what actually identifies a schedule. Five dimensions fragment hard: every one multiplies the group count and pushes more groups under min_jobs, so expect summary.groups_insufficient to be large and drop build_year first when it is — it fragments hardest and explains least. Grouping happens in PHP, because the milestone does not exist until the extractor has run and there is nothing for SQL to GROUP BY. A value outside the list is a 400. A milestone dimension always keys on the unit-aware label, whichever of the three you name, and every row carries all five milestone fields — service_interval, service_value, service_unit, service_km, service_hours — not just the one you grouped by. Taken literally, group_by=…,service_km would put every job measured in hours into one bucket keyed on null, averaging 500 h, 1000 h and 1500 h services together into a row that named no milestone at all. Naming service_km now narrows which rows get a number, never which jobs share a row.
build_yearstringoptComma list of years, e.g. 2023,2024. YEAR(WKVEHFL.BUILD_DATE) — the day and time on that timestamp are noise, since 2024-07-11 00:00:00 is a 2024 vehicle and nothing finer separates one service from another. ?year= is an alias. A year the DMS cannot mean (outside 1950…this year + 2) becomes null and is counted in extraction.implausible_build_years, so a column full of placeholder dates shows up as a number rather than as a cohort of 1900 vehicles.
grainstringoptjob (default) | ro. What one observation is. ro sums the surviving lines of a card into a single observation — right wherever a service is routinely split across two lines, because at job grain those are two half-length observations and the benchmark reads low. Not the default because it moves every number. extraction.jobs_per_ro says whether the two grains would differ at all on your data.
repair_rulestringoptstandin (default) | all | none. When a Repair line counts as a service — see the panel above. all is the inflating mode; none is the pre-2026-09-10 behaviour, and still lets repair lines donate their interval.
ro_intervalintegeropt0 = do not inherit an interval across a card. On by default: this is what gives a line reading only Work Requested:SERVICE the 90 000 typed on the repair line beside it. The first thing to switch off if a benchmark looks wrong in a way nothing else explains.
date_fromdateoptYYYY-MM-DD. Default today − 364 on the database clock. A year, not a fortnight — see above.
date_todateoptYYYY-MM-DD. The range is half-open on the raw timestamp columns, so the last day is whole.
datedateoptOne day — sets both bounds.
date_basisstringoptclocked (default) | start | finish. clocked is DATE_CLOCKED_IN falling back to START_TIME, split into two OR-ed single-column ranges so each arm seeks its own index.
makestringoptExact comma list, case-folded — an install storing Isuzu still matches ISUZU.
variantstringoptExact comma list, case-folded. WKVEHFL.VARIANT.
modelstringoptExact comma list, case-folded. model_name is a separate filter and a separate dimension.
regstringoptExact comma list. One vehicle's own service history — pair it with ?jobs=1.
branchstringoptExact comma list on WKMECHWK.RO_BRANCH. Applied inside the pre-aggregate so the index still does the work. branch_exclude is the negation.
franchisestringoptExact WkRoFile.FRANCHISE code list, compared as typed.
job_code_matchstringoptSubstring comma list, default SERVICE,REPAIR — the same test behind the friendly labels on WIP Labour and Technician Work. This gate says which lines are read, never which become observations — that is repair_rule's job. all drops the gate entirely.
job_codestringoptExact comma list. Overrides job_code_match entirely.
job_typestringoptThe labels — Retail, Fleet, Warranty, Internal, Policy, Sundry, Excess, Project Billing — filtered on the same expression that emits them, so a value you were given round-trips.
ro_numberintegeroptOne RO, found wherever it sits. Ignores the dates entirely — a drill-down does not know which year the job fell in.
service_intervalstringoptComma list of labels, e.g. 15000km,1000h. Matched loosely — 15000 KM, 1000 hours and 1000h all normalise the same way — so a value the response gave you can be pasted straight back.
service_kmstringoptComma list, e.g. 15000,30000. Kilometre readings only, so ?service_km=1000 can never return a tractor's 1000 hours. Filtered after extraction, in PHP — it is a post-hoc value, not a column.
service_hoursstringoptThe same, for hours readings only, e.g. 500,1000.
unitsstringoptboth (default) | km | hours. Switches whole rules off. A dealer with no machinery can set km and be certain no 1000 H in a comment ever reaches a benchmark; a farm-equipment branch can set hours. extraction.by_unit says how much of the workshop is which.
km_minintegeropt1000. Below this a kilometre reading is rejected as implausible and counted in extraction.out_of_band_rejected, with samples so the band can be argued with. This floor is what throws out the (45 KM) drive to the farm.
km_maxintegeropt999999. This install's data reaches 480.000KM, so do not lower it casually on a commercial fleet.
km_stepintegeropt0 = report the reading exactly as read. Set e.g. 5000 to snap kilometre readings to a grid before grouping; service_value_exact stays on every job row either way.
hours_minintegeropt50 — below the smallest first service anyone runs. Hours have their own band because the scales have nothing to do with each other: one band wide enough for a 500-hour tractor service and a 90 000 km vehicle service is no band at all.
hours_maxintegeropt50000, well past the life of an engine.
hours_stepintegeropt0 = exact. The hours equivalent of km_step.
rule_setstringoptstrict (tier 1 only — an explicit KM or hours unit written down) | km (default, + the 90K abbreviation) | all (+ the two unitless rules). ?rules=1 lists all five with their regexes, tiers and units, and the words it matches for "service".
min_jobsintegeropt3. A group below this carries no average and is not returned — see the panel above. 1 turns the guard off entirely, which gives those groups real averages rather than merely showing them.
show_insufficientflagopt1 = return the under-observed groups as well, as rows of nulls with sufficient: false. Off by default. Use it to answer "why is this variant missing from the benchmark" — the answer is usually one job. summary.groups_insufficient and summary.services_in_insufficient_groups count them either way, so nothing is hidden by the default.
with_unknownflagopt1 = keep services whose interval could not be read, grouped under service_km: null. Off by default; summary.jobs_without_interval counts them either way.
include_zero_hoursflagopt1 = put the jobs whose clocking sums to zero back. There so the size of that population can be seen — not a mode for producing a benchmark. It does not bring back jobs whose hours column is NULL on every line: those are dropped unconditionally and counted as extraction.jobs_with_no_hours_recorded.
outlier_hoursnumberoptDrop services above N hours. Off by default and deliberately undefaulted.
reversalsstringoptnet (default) | include | exclude | only. Same four modes as Technician Work. exclude keeps the original and drops its reversal, which overstates.
sortstringoptjobs (default, desc) | hours_avg | make | variant | service_km. Insufficient rows sink to the bottom of an hours_avg sort rather than leading it as nulls.
jobsflagopt1 = the drill-down: one row per service, with its hours, its extracted interval, which rule read it, and the free text it was read from. ?full_text=1 keeps the text whole instead of trimming it to 300 characters.
intervalsflagopt1 = the service schedule per make and variant: the inferred step, the milestones behind it, whatever WKVEHFL declares, and whether the two agree. See the panels above.
interval_bystringoptDimensions the step is inferred over. Default make,model,variant — deliberately not build_year, unlike the grouping: hours plausibly change with the model year, so the benchmark splits on it, but a schedule almost never does, and splitting would divide the same evidence across five year-cohorts and leave each below the minimum. The step is a property of the car; the year is a property of the example. Refuses any milestone dimension — the step is inferred from the readings, so grouping by one would give every group a single milestone and nothing to find. The unit is always part of the key automatically, so a group holding both machines and vehicles comes back as two rows without being asked. The step is attached to a benchmark row only when every interval_by dimension is also in group_by.
interval_fitnumberopt80. The minimum percentage of jobs a candidate step must explain. Raise it and only clean schedules resolve; lower it and a step will be found for almost anything. The threshold is what stops the pick reaching for a step so large it explains one milestone.
interval_min_levelsintegeropt2, floored at 2. Distinct milestones needed before a step is inferred at all. One milestone is a service, not an interval.
interval_min_jobsintegeropt3. Jobs needed behind those milestones. Two services on one car do not establish a schedule.
candidatesflagopt1 = include by_candidate on ?intervals=1 — every step that was tried, its fit, and which one was chosen. Off by default because a dozen candidates per group is more payload than the answer on a whole-dealer call; on when you want to argue with a step.
unmatchedflagopt1 = the tuning mode. Counted over every service job in the window, including the ones the benchmark holds out — how well the rules read this shop's English is a question about the text, not about which jobs made it into an average.
extract_testflagopt1 with ?text= — run the extractor over your own string. Touches no database, so it works when the connection is the thing that is broken.
textstringoptThe string extract_test reads. Try Work Requested:1 90.000KM SERVICE.
rulesflagopt1 = the four rules, their regexes, their examples and which are enabled. No database.
facetsflagopt1 = the picker lists, plus a make → variant cascade. Built from the same population as the benchmark and inheriting every filter, so a branch-limited caller is never offered a value their data cannot return.
schemaflagopt1 = live columns for WKMECHWK / WKOTHSUB / WKROFILE / WKVEHFL, what bound to what, and a shortlist of columns that look like the free text or the hours. Run this first on a new install.
detail_colstringoptPin the WKOTHSUB free-text column by name when the probe picks the wrong one. hours_col, est_hours_col, standard_hour_col and serv_cyc_km_col do the same for the others.
max_jobsintegeropt100000 scan cap, clamped to 500000. extraction.truncated says when it was hit — a truncated aggregate is not the aggregate you asked for.
probeintegeropt0 = skip the catalog probe and trust the default spellings.
pageintegeropt1.
limitintegeroptRows per page; blank = all.
debugflagopt1 = per-query timing / size in a _debug block.
json
{ "meta": {
    "source": "WKMECHWK (clocking) + WKOTHSUB (job + free text) + WkRoFile + WKVEHFL",
    "grain": "one row per make × variant × service_km",
    // false until a real ?debug=1 run against a live DMS says otherwise
    "measured": false,
    "window": { "from": "2025-09-11", "to": "2026-09-10" },
    "date_basis": "clocked", "reversals": "net",
    // ───────── the default benchmark ─────────
    "job_gate": "job_code contains: SERVICE,REPAIR",
    "observation": "job", "repair_rule": "standin",
    "ro_interval": true, "rule_set": "km",
    // the inference scores itself on every response
    "extraction": {
      "jobs_scanned": 6934, "jobs_after_dedup": 6934,
      "vehicle_duplicate_rows": 0,
      "jobs_with_detail_text": 6710, "jobs_with_km": 3298,
      "coverage_pct": 47.6,
      "by_rule": { "km_suffix": 2604, "h_suffix": 669, "k_suffix": 25 },
      // tractors are a fifth of this workshop, and were invisible before
      "by_unit": { "km": 2629, "hours": 669 },
      "out_of_band_rejected": 14,
      // a number on a repair line with no "SERVICE" beside it — an odometer
      "km_without_service_word": 192,
      "repair_orders_scanned": 3915,
      // text-less SERVICE lines that took the interval off another line
      "ro_interval_inherited": 641,
      "ro_interval_conflicts": 18,
      "coverage_pct_after_ro": 56.8,
      "repair_standins": 204, "repair_jobs_dropped": 2503,
      "sublet_other_dropped": 88, "jobs_per_ro": 0.79,
      // hours NULL on every line — dropped even with include_zero_hours=1
      "jobs_with_no_hours_recorded": 146,
      // clocking that SUMS to zero — a measurement, so the flag returns it
      "zero_hour_jobs": 311, "negative_hour_jobs": 4,
      "jobs_without_vehicle": 52,
      // BUILD_DATE empty, and BUILD_DATE holding a year nobody can mean
      "jobs_without_build_year": 318,
      "implausible_build_years": 7, "truncated": false,
      "warning": null } },
  "summary": { "services_measured": 3102,
    "observation": "one service job",
    // groups = every group; groups_returned = the ones carrying an answer
    "groups": 211, "groups_returned": 133,
    "groups_insufficient": 78,
    "services_in_insufficient_groups": 104,
    "jobs_without_interval": 928,
    // Repair here = cards that had NO service line, so the repair WAS the service
    "by_job_class": { "Service": 2898, "Repair": 204 },
    // "ro" = the interval was read off a different line of the same card
    "by_interval_source": { "own": 2461, "ro": 641 },
    "hours": { "avg": 3.41, "median": 2.9, "p25": 1.8,
      "p75": 4.5, "min": 0.25, "max": 31.5, "stddev": 2.18 } },
  // group_by = make · model · variant · build_year · service_km
  "rows": [
    { "make": "ISUZU", "model": "D-MAX",
      "variant": "250 HO HI-RIDE D/C", "build_year": 2024,
      "service_interval": "90000 km", "jobs": 37, "sufficient": true,
      "hours_avg": 4.12, "hours_median": 3.8,
      // a quarter of these services came in under 3.1 h and a quarter
      // took over 4.9 h — the middle half of the jobs sat between them
      "hours_p25": 3.1, "hours_p75": 4.9,
      "hours_min": 2.0, "hours_max": 11.25,
      "hours_stddev": 1.44, "hours_total": 152.44,
      "hours_sold_avg": 3.6,
      // the shop's own booking figure, and how far the actual sits from it
      "est_hours_avg": 3.5, "standard_hour_avg": 3.5,
      "variance_vs_est_pct": 17.7,
      // the car's step, inferred across ALL its milestones, and which
      // service in that schedule this row is: 90000 / 15000 = the 6th
      "interval_value": 15000, "interval_unit": "km", "interval_fit_pct": 98.7,
      "service_number": 6,
      "interval_agrees_with_declared": true },
    // a tractor. ALL FIVE milestone fields are on every row, so an hours
    // reading is never a bare "service_km": null with the number nowhere.
    { "make": "JD", "model": "6110M",
      "variant": null, "build_year": null,
      "service_interval": "1500 h", "service_value": 1500,
      "service_unit": "hours",
      "service_km": null, "service_hours": 1500,
      "jobs": 8, "sufficient": true,
      "hours_avg": 4.44, "hours_median": 4.25,
      "hours_p25": 3.75, "hours_p75": 5,
      "interval_value": 500, "interval_unit": "hours",
      "interval_label": "500 h", "interval_fit_pct": 100,
      // 1500 h on a 500 h schedule — the third service
      "service_number": 3,
      "interval_agrees_with_declared": null },
    // n below min_jobs is NOT here — only under ?show_insufficient=1, where
    // it looks like this. summary.groups_insufficient counts it either way.
    { "make": "ISUZU", "model": "FTR",
      "variant": "850 AMT", "build_year": 2021,
      "service_interval": "480000 km", "jobs": 2, "sufficient": false,
      "hours_avg": null, "hours_median": null,
      "note": "Fewer than 3 observations — no average is reported." }] }

// ───────── ?intervals=1&candidates=1 — the schedule per make · variant ─────────
{ "summary": { "groups": 64, "intervals_inferred": 51,
    "no_interval": 13,
    // honest denominator: groups where SERV_CYC_KM is empty are in neither count
    "checkable_against_dms": 38,
    "agree_with_declared": 35, "disagree_with_declared": 3 },
  "rows": [
    { "make": "ISUZU", "variant": "D-MAX 250 DDi-N HO",
      "jobs": 228, "levels_total": 7,
      "service_unit": "km",
      "levels": [{ "service_value": 15000, "jobs": 61 },
                 { "service_value": 20000, "jobs": 2 },
                 { "service_value": 30000, "jobs": 54 },
                 { "service_value": 45000, "jobs": 44 }],
      "interval_value": 15000, "interval_label": "15000 km",
      "interval_fit_pct": 99.1,
      "levels_on_grid": 6,
      // the mistyped one, named rather than silently absorbed
      "off_grid": [{ "service_value": 20000, "jobs": 2 }],
      // LARGEST above interval_fit wins — 5000 also fits 100%, and is not it
      "by_candidate": [{ "step_km": 15000, "fit_pct": 99.1, "chosen": true },
                        { "step_km": 10000, "fit_pct": 28.5, "chosen": false },
                        { "step_km": 5000,  "fit_pct": 100,  "chosen": false }],
      "declared_cycle_km": [15000], "agrees_with_declared": true },
    // a tractor: the same group can hold BOTH units, and comes back as two
    // rows rather than one meaningless merge. No DMS column to check against.
    { "make": "JOHN DEERE", "model": "6110M",
      "variant": "6110M CAB", "service_unit": "hours",
      "jobs": 54, "levels_total": 6,
      "levels": [{ "service_value": 500, "jobs": 14 },
                 { "service_value": 1000, "jobs": 12 },
                 { "service_value": 1500, "jobs": 11 }],
      "interval_value": 500, "interval_unit": "hours",
      "interval_label": "500 h", "interval_fit_pct": 100,
      // null, not false: SERV_CYC_KM is kilometres by name, so a machine
      // has no independent check and is held out of the agree/disagree counts
      "interval_km": null, "agrees_with_declared": null },
    // the offset schedule: a running-in service at 5000, then every 15000
    { "make": "HYUNDAI", "variant": "GRAND i10 1.0 M",
      "jobs": 30, "levels_total": 3,
      "interval_value": 5000, "interval_fit_pct": 100,
      "common_difference": 15000,
      "reading": "every 15000 km from 5000 km. interval_km is 5000 because …",
      "agrees_with_declared": null },
    // no step explains 80% — usually two vehicles under one variant name
    { "make": "ISUZU", "variant": "FTR 850 AMT",
      "jobs": 9, "levels_total": 4, "interval_value": null,
      "reason": "no candidate step explains 80% or more of the jobs …" }] }

Advisors

Reads VhSalman — the advisor master — joined to CONTACT on CODE = contact_code: one row per advisor code with the name, mobile and email, branch, manager and default franchise. It is a mix of service advisors and vehicle sales people, and nothing on the row tells them apart; this answers everybody until the rule that picks out service advisors is defined, and says so in meta.note. The code is the one bookings already carry — WKROFILE.SALESMAN, returned by Service Bookings as sales_advisor_code — so a booking's advisor matches a row here code to code. Built for ServiceConnect, which sets its service advisors up from this rather than typing them in. The column list is fixed and deliberately short of the table: PIN_CODE is a credential, and the invoice totals, RO values, purchase limit, vehicle budgets, sales and profit bands and showroom / F&I access rights are pay, targets and permissions, not who somebody is. Read only.

GET advisors.php ?branch= &code= &active= …

Every advisor not terminated, ordered by surname, then name, then code. Every key is on every row in a fixed order, lower-case and aliased in the SQL. Values are cleaned here: strings pass through toUtf8(), are trimmed of their CHAR padding, and a blank is null. mobile is MOB_PHONE alone, never filled from a landline — the rule the technician roster and bookings apply, because an SMS to a landline fails while looking sent; phone_alt is the private, else business, number for a person to dial, and email is EMAIL_ADDRESS, else EMAIL_ADDRESS_2. name is never blank: a code with no CONTACT row reads as #1042 with has_contact_record: false, so a list has something to show and a consumer something to notice. is_terminated is a boolean and termination_date a date or null.

KeyTypeRequiredDescription
branchstringoptComma list of exact branch codes, e.g. M0101,M0102, on the trimmed VhSalman.branch. Nothing matching is 200 with count: 0. Blank is every branch
codestringoptComma list of exact advisor codes, e.g. 1042,1055 — the codes bookings carry as sales_advisor_code
activestringopt1 (default) leaves out anybody whose Is_Terminated is Y — the list to pick from. 0 answers everybody
debugintegeropt1 = the usual profiler: the query runs, and _debug carries its SQL, ms and row count
json
{ "meta": { "source": "VhSalman (advisor master) + CONTACT on CODE = contact_code",
    "grain": "one row per advisor code", "active_filter": "not terminated",
    "note": "VhSalman holds service advisors AND vehicle sales people …" },
  "count": 1,
  "data": [{ "code": "1042", "name": "Piet Advisor", "has_contact_record": true,
    "first_name": "Piet", "surname": "Advisor",
    "mobile": "082 123 4567", "phone_alt": "013 712 3456", "email": "piet@example.co.za",
    "branch": "M0102", "manager_code": "1001", "default_franchise": "IS",
    "is_terminated": false, "termination_date": null }] }

Job Codes

The job code master — WkCodeFl — one row per code with its description, work category and estimated hours, ordered by what the branch actually books. Create Job Card requires a job_code and a job_type on every job and validates neither beyond its width, so a caller sending a code this dealership does not hold raises a card nothing prices and no screen recognises. This is the list a booking screen offers. The first cut of this endpoint read distinct codes back off WKOTHSUB, because the master's table name had never been given to this API and guessing at one is how an endpoint works at one install and 500s at the next. The name is WkCodeFl, and reading it is better in every way that matters: DESCRIPTION is what the workshop reads off the card, EST_HOURS is a number the create can be given, and Work_Cat means choosing a code answers the work category too rather than asking a second question. Use still orders it. The master holds every code the franchise ships and a branch books a few dozen of them, so WKOTHSUB is joined in for a count over ?days and the commonest come first — a service advisor should not scroll to reach SERVICE. The master is not per branch: WkCodeFl has no branch column (PART_BRANCH is about where parts come from), so ?branch only decides whose bookings do the ordering. Read only.

GET workshop_job_codes.php ?branch= &q= &days= …

One row per code on the master, ordered by uses then code. The count comes from one grouped pass over WKOTHSUB rather than a correlated subquery per row — three hundred rows each counting their own matches is three hundred scans of the busiest table in the workshop. work_cat, est_hours and labour_units are the code's own, so a consumer that picks a code already has them. uses is 0 for a code the branch has not booked inside the window, and such codes are still listed — the master is the authority on what exists, and use is only the ordering. work_cats is the distinct Work_Cat across the master, for a picker of its own; a category with no code against it is not one this dealership can book, so the list is exactly the codes' own values. A work_cats read that fails answers an empty array rather than taking the job codes down with it.

KeyTypeRequiredDescription
branchstringoptOne branch code, e.g. 100, on the trimmed WKOTHSUB.RO_BRANCH. It does not filter the master — WkCodeFl has no branch — it decides whose bookings order the list. Blank counts across the dealership
qstringoptNarrows to codes whose CODE or DESCRIPTION contains this, ignoring case. For a picker with a search box on a master of thousands
daysintegeroptThe window the uses count is taken over, default 365, clamped to 30–3650. A year is what makes the ordering "what this branch does now" rather than what it once did
limitintegeroptHow many codes to return, default 300, clamped to 1–2000. The default is a picker's worth; raise it to mirror the master
debugintegeropt1 = the usual profiler: the queries run, and _debug carries their SQL, ms and row counts
json
{ "source": "WkCodeFl (job code master), counted against WKOTHSUB",
  "branch": "100", "days": 365, "q": null, "count": 2,
  "job_types": { "R": "Retail", "F": "Fleet", "I": "Internal", "W": "Warranty",
    "P": "Policy", "S": "Sundry", "E": "Excess", "B": "Project Billing" },
  "work_cats": [{ "work_cat": "SERVICE", "codes": 412 }],
  "data": [{ "job_code": "SERVICE", "description": "Minor service",
    "work_cat": "SERVICE", "work_type": "RT", "service_type": "S",
    "work_class": null, "est_hours": 1.5, "labour_units": null,
    "make": null, "model": null,
    "uses": 388, "last_used": "2026-09-25 09:40:36.000" }] }

Technicians

Reads WKMECHFL joined to CONTACT — the technician master file. One row per technician with name, mobile and email, branch, team, competencies, rates and employment dates. This is the roster, so it includes technicians who are idle, on leave or newly hired — which WIP Labour Detail's technician picker cannot, since that list comes from open repair orders.

This one also writes. POST to the same file edits one technician — branch, team, schedule, bay, rates, overtime, the competency arrays, employment dates, and the CONTACT-side name, mobile and email — and answers with the row re-read through the shaping below. One file in both directions because the roster is a thing a dealer maintains, and a client that renders a technician should be able to correct one. It never creates a technician and never touches pin_code. The write needs the WRITEKEY header; the read half is unchanged.

GET workshop_technicians.php ?branch= &active= &as_of= …

Active means active when. The default is "employed as at today", not "not flagged terminated" — as_of moves that date and the roster is rebuilt from start_date and termination_date. There is no skill-level column: work_classes, franchises and guild_nos are competency arrays, not a grade. mobile, phone_alt and email come back on every row — the old ?contact=1 flag is gone and is now ignored if sent. mobile is MOB_PHONE alone and is never filled in from a landline, so an SMS run can trust it; every other number lands in phone_alt. Keys are always present, null when CONTACT has nothing. Run ?schema=1 once per install. pin_code is never returned.

KeyTypeRequiredDescription
activestringopt1 (default) employed as at as_of | 0 terminated | all
as_ofdateoptYYYY-MM-DD — the date employment is judged on. Defaults to today on the database clock.
branchstringoptHome branch, comma list. branch_include is the alias; branch_exclude removes.
technician_codestringoptComma list. mechanic_code is accepted as an alias — that is what the column is called on WKMECHWK and in every WIP filter.
teamstringoptTEAM_CODE(s), comma list
work_classstringoptMatches any of the five work-class slots — the slots carry no ordering, so slot 1 is not "the primary one"
franchisestringoptMatches any of the four franchise slots
work_baystringoptDefault_Work_Bay, comma list
labor_typestringoptShop | Field (S / F accepted)
searchstringoptPartial on name, surname or technician code
codesflagopt1 = add CODTYP descriptions beside the work-class / franchise codes. Needs work_class_type and/or franchise_type — there is no safe default, since codes collide across CODTYP types.
work_class_typestringoptThe CODTYP type letter holding the work-class codes. ?schema=1 finds it.
franchise_typestringoptThe CODTYP type letter holding the franchise codes
include_noteflagopt1 = add the free-text NOTE column
code_listsflagoptOn by default; 0 suppresses. Adds meta.team_codes (every team on CODTYP type TC) and meta.schedule_codes (every code on WKMECHSC) to the ordinary list response — the two vocabularies a client needs to edit a technician rather than merely render one. These are not facets. ?facets=1 answers which teams are in use, built from the joined query so a picker entry can never return an empty result set; these answer which teams exist, including the team nobody is in yet — which is precisely the edit somebody opens the write path to make. Label the team picker from long_description, not name: the team on every data row is CODTYP.long_des, and a picker labelled from the other spelling shows one string in the dropdown and a different one on the row it just saved. A schedule code can appear twice — WKMECHSC is grouped by (Schedule_Code, TYPE) while WKMECHFL.Schedule_Code stores the code alone, so compare meta.code_lists.schedule_codes_distinct against the number of entries and dedupe when it is lower, or the second option is unreachable. WKMECHSC is the one soft read in this file: no query in this API had ever named that table, so an install that spells it differently loses the dropdown — meta.schedule_codes goes null with the driver's message in meta.code_lists.schedule_codes_error — rather than losing the roster.
facetsflagopt1 = the picker lists (branches, teams, work classes, franchises, bays) and nothing else. Each list ignores its own filter, so a picker never narrows itself.
schemaflagopt1 = one-shot discovery: CODTYP type letters, the schedule-code table hunt, and the live column lists
sortstringoptname (default) | code | branch | team | start
pageintegeroptPage number
limitintegeroptRows per page. Blank = all rows.
json
{ "meta": { "as_of": "2026-08-25", "as_of_basis": "database clock (GETDATE())" },
  "summary": { "technicians": 18, "active": 16, "terminated": 2,
    "undated_terminations": 0, "by_branch": { "M0101": 11, "M0102": 7 } },
  "data": [{ "technician_code": "T014", "technician_name": "Sipho Ndlovu",
    "has_contact_record": true,
    "mobile": "0824445566", "phone_alt": null,
    "email": "sipho.ndlovu@dealer.co.za", "branch": "M0101",
    "team_code": "A", "team": "Diagnostics", "schedule_code": "DAY",
    "default_work_bay": "BAY1", "default_work_location": "Shop",
    // competency SETS, not a seniority grade — there is no skill_level column
    "work_classes": ["EL", "DS"], "franchises": ["TY"], "guild_nos": ["G12345"],
    "hour_cost_rate": 185.00, "eff_factor": 100,
    "overtime": { "load_percent": 150, "cost_percent": 150 },
    "is_active": true, "is_terminated": false,
    "start_date": "2019-03-04", "termination_date": null,
    "service_days": 2731, "service_years": 7.5 }] }
POST workshop_technicians.php ?dry_run=1 &allow_undated_termination=1 …

Edits one technician. The roster is the one workshop feed a dealer actually maintains — everything else in this API describes what happened, this describes who is on the floor, and it goes stale the moment somebody moves branch, changes team, gets a new rate or leaves. Every field is optional and only what you send is written; "" and null both mean clear it, and an omitted key leaves the column exactly as it was. PATCH is accepted as well as POST.

Two tables, one body. A technician row is spread across WKMECHFL (branch, team, schedule, bay, rates, overtime, competencies, employment dates) and CONTACT (name, mobile, email), joined on Code = contact_code — so the client sends back the row shape it read and this splits it. When both halves are written they go inside one transaction: a rate change that lands while the name change rolls back is a row nobody can reason about. If the driver refuses to release autocommit it says transactional: false rather than pretending. A technician with no CONTACT row (has_contact_record: false) cannot take contact edits — that is a 409, not a silent no-op, because this endpoint will not invent a person master.

It updates. It never creates, and it never touches pin_code. WKMECHFL.Code is the technician key and — exactly like contact_code, see Create Customer — nothing in this codebase knows who allocates it on your install. A new technician is also a new CONTACT row, a PIN and a schedule: a DMS workflow, not an UPDATE. An unknown technician_code is a 404. pin_code is a clocking credential: the read half refuses to emit it and the write half refuses to set it. The hours tables (WKMECHWK, WKMECHADJ) are not touched either — this is the master file only, the same line Update Contact draws at the AR ledger.

The competency arrays replace, they do not append. work_classes, franchises and guild_nos are read as collapsed arrays, so the only sane write is the same shape: the array you send becomes slots 1..n in order and every remaining slot is set to NULL. Sending ["EL"] for a technician who had ["EL","DS"] removes DS. An append-only write could never express a removal, and a client editing a multi-select sends the whole set anyway. A comma string is accepted too, so the same value that filters the list can be written back.

Terminating needs a date. is_terminated: true with a null termination_date is the one case the roster cannot resolve: the leaving date is not on file, so the technician has to be treated as terminated on every historical date, and their whole past disappears out of every headcount — an error that grows the further back you look and that nothing reports. So this refuses to create one. Send termination_date, or have one already on the row, or pass ?allow_undated_termination=1 when the date genuinely is not known (the response then says so, and summary.undated_terminations counts them). Reinstating clears it: is_terminated: false nulls termination_date unless you supply one in the same body, because a stale leaving date on an employed technician is the same ambiguity pointing the other way.

Keys you read but cannot write are accepted and ignored. technician_name, team, is_active, service_days, service_years, phone_alt and the *_descriptions maps are derived, so a client can send the object it rendered without stripping them first. Any other unrecognised key is a 400 listing the editable fields — a misspelled column silently dropped is an edit the user watched succeed and that never happened. One asymmetry worth knowing: email is COALESCE(EMAIL_ADDRESS, EMAIL_ADDRESS_2) on the way out and writes to EMAIL_ADDRESS on the way back, so echoing a whole row back promotes a second-slot address into the first. Send only the fields the user changed — which is right anyway.

The response is the committed row, re-read through the same projection and shaping a GET uses, so what the client stores after a write is byte-identical to what the next read hands it rather than its own optimistic copy. No modification stamp on WKMECHFL: it carries no Last_Modified_Date column this codebase has seen, so only the CONTACT half is stamped and there is no audit trail on the master file beyond the DMS's own.

KeyTypeRequiredDescription
dry_runflagopt1 = return both statements with their bound parameters, and every decision that got them there, and commit nothing. Run this first.
allow_undated_terminationflagopt1 = terminate without a termination_date. Off by default — see the warning above. The response names what it did.
include_noteflagopt1 = include the free-text NOTE on the echoed row. Set automatically when the body writes note.
debugflagopt1 = per-query timing / size in a _debug block.

Body (JSON). technician_code identifies the row and is the only required key (mechanic_code is accepted as an alias). WKMECHFL: branch · team_code · schedule_code · default_work_bay · dealer_account_no · default_work_location (Shop|Field) · hour_cost_rate · eff_factor · ot_load_percent · ot_cost_percent · ot1_load_percent · ot1_cost_percent · ot2_load_percent · ot2_cost_percent (or the same six nested under overtime, as the read emits them) · is_terminated · termination_date · start_date · note · work_classes[] · franchises[] · guild_nos[]. CONTACT: first_name · surname · mobile · prv_phone · bus_phone · email · email_address_2, flat or nested under contact. Rates and percentages must be numeric and non-negative; dates are YYYY-MM-DD; emails are validated.

json — move a technician to another branch and team, and fix his mobile
{ "technician_code": "T014",
  "branch": "M0102", "team_code": "B",
  "hour_cost_rate": 195.00,
  "work_classes": ["EL", "DS", "AC"],
  "mobile": "0824445566" }
// work_classes REPLACES all five slots: whatever is not in this array is cleared.
// mobile goes to CONTACT.MOB_PHONE; the two UPDATEs run in one transaction.
json — a leaver
{ "technician_code": "T022",
  "is_terminated": true, "termination_date": "2026-09-30" }
// without termination_date this is a 400, not a silent undated termination —
// an undated leaver reads as terminated on every historical date.
json
{ "status": "ok", "technician_code": "T014", "contact_code": "T014",
  "fields_updated": { "technician": ["branch", "team_code", "hour_cost_rate", "work_classes"],
                       "contact": ["mobile"] },
  "rows_affected": { "WKMECHFL": 1, "CONTACT": 1 },
  // false would mean the driver refused to release autocommit — the two
  // statements still ran in order, but one could have landed without the other
  "transactional": true, "as_of": "2026-09-04",
  // the committed row, shaped exactly as GET shapes it
  "technician": { "technician_code": "T014", "technician_name": "Sipho Ndlovu",
    "branch": "M0102", "team_code": "B", "team": "Mechanical",
    "work_classes": ["EL", "DS", "AC"], "hour_cost_rate": 195.00,
    "mobile": "0824445566", "is_active": true } }

Technician Attendance

Reads WKMECHADJ — technician hours per day, and the denominator behind every efficiency and utilisation figure in the workshop. Availability is read from the DMS, not modelled from an assumed eight-hour day. Pair it with Technician Work: hours available here, hours clocked there.

This one also writes. POST to the same file upserts a whole technician-day — the roster entry itself, not a report about it. One file in both directions because this endpoint owns WKMECHADJ outright, the argument Parts Sales Orders makes for its two tables. The write needs the WRITEKEY header; the read half is unchanged.

GET workshop_technician_attendance.php ?date_from= &group_by= &branch= …

Only AD counts as available by default. Every other TYPE is still returned under hours_by_type but kept out of hours_available — a public holiday means the day is accounted for, not that capacity existed. Nothing is inferred from the code letters; run ?types=1 for the census on your data. is_working_day is read, never derived from the weekday, so Saturday shifts and shutdown weeks are correct. A date with no row is absent rather than zero — ?fill=1 emits it as has_record: false.

KeyTypeRequiredDescription
group_bystringoptday (default, one row per technician per date) | line (raw WKMECHADJ rows) | technician | date (shop-level capacity line)
date_fromdateoptYYYY-MM-DD, inclusive on ADJ_DATE. Independent of date_to.
date_todateoptYYYY-MM-DD, inclusive. Set neither for today − 6 → today + 7 — rosters are entered both behind and ahead, so the default spans both directions. Max range 370 days.
datedateoptPins both bounds to one day
available_typesstringoptWhich TYPEs sum into hours_available. Default AD.
typestringoptOnly these attendance TYPEs, comma list
paidstringoptY | N
technician_codestringoptComma list. mechanic_code is an accepted alias.
branchstringoptWKMECHADJ.TECH_BRANCH, comma list. branch_exclude removes.
home_branchstringoptWKMECHFL.BRANCH, comma list
teamstringoptTechnician's team, comma list
working_onlyflagopt1 = only days with availability on them
fillflagopt1 = emit dates with no WKMECHADJ row, as has_record: false. group_by=day and date only.
include_terminatedflagopt1 = keep technicians already terminated at date_to
typesflagopt1 = the TYPE census and nothing else. Covers all history by default; ?types_window=1 restricts it to the current date range.
pageintegeroptPage number
limitintegeroptRows per page. Blank = all rows.
json
{ "meta": { "available_types": ["AD"],
    "hours_basis": "read from WKMECHADJ — not modelled from a standard day" },
  "summary": { "rows": 252, "hours_available": 1904.0,
    "hours_by_type": { "AD": 1904.0, "PH": 144.0, "SL": 32.0 } },
  "data": [{ "technician_code": "T014", "technician_name": "Sipho Ndlovu",
    "date": "2026-08-27", "branch": "M0101", "home_branch": "M0101",
    "is_working_day": true, "has_record": true,
    "hours_available": 8.0, "hours_total": 8.0, "hours_paid": 8.0,
    // every TYPE on the day — PH is returned but NOT counted as available
    "hours_by_type": { "AD": 8.0 },
    "first_start": "07:30", "last_end": "16:30", "lines": 1 }] }
POST workshop_technician_attendance.php ?probe=1 &dry_run=1 &merge=1 &replace=1 …

Upserts one technician-day, or a batch of them, on WKMECHADJ. The unit is (tech_code, date) — never the individual row — and the endpoint makes the table match what it works out the day should be: delete what is there, insert the result. Under ?merge=1 that result is the day already on file with your lines folded in; under ?replace=1 it is exactly the body. An empty lines array with ?replace=1 is the way to clear a day; an omitted lines key is a 400, because "I forgot to send the lines" and "I mean to delete this day" must not be the same request.

Many technicians in one call: send {"days": [ … ]} where each entry is exactly the single-day body. There is no second schema, so the two cannot drift. A batch is validated whole — every technician resolved, every line checked, every existing day read, every LINE_NO settled — and then executed as one transaction: day 7 being wrong means days 1–6 were not written either. That is the point. A client looping single POSTs gets one chance per technician to half-finish a shift, and a half-finished roster does not announce itself — it shows up later as a capacity denominator that is quietly too small for whoever's POST never landed. Cap is 200 days per request. The same (tech_code, date) twice in one batch is a 400: each entry is a complete day, so the second one's DELETE would remove the first one's INSERTs and the response would call both a success. A body carrying both days and a top-level tech_code is also a 400 — resolving it would mean guessing which half you meant.

Who allocates LINE_NO — and on this install, nobody does. WKMECHADJ carries one — ?group_by=line orders by it — and nothing in this API had ever written one. The DDL declares it INTEGER NOT NULL with no usable default, and a browse of the live table (27 048 rows) carries LINE_NO = 0 on every row. So the first line of a day is keyed 0, not 1 — and a day's second line is 1. The data cannot tell a zero-based sequence from a column that is always zero: every observed technician-day holds exactly one line, so both readings produce the same 27 048 rows. The sequence reading is taken because it is the only one safe under both — numbering 0,1,2 keeps every first line at 0, which is what the whole table shows, and gives a second line a key of its own so it cannot collide whether or not a unique key exists. ?probe=1 reports max_lines_in_a_day; the day that exceeds 1 while highest_line_no stays 0 is the day this reading is disproved, and the write says so in meta.line_no_warning the first time it creates a multi-line day. On an install that numbers from 1 or numbers globally, the hazard is real and unchanged — guess "per day" when it is global and every insert collides on the key; guess "global" when it is per day and the numbers drift upward forever, harmless until an Auto-IT screen that assumes 1..n renders the day wrongly, by which time months of rows are keyed that way. So it does not guess. ?probe=1 answers it with data and writes nothing.

?probe=1 first, then ?dry_run=1, then commit. The probe returns the live SYSCOLUMN list for WKMECHADJ — nullability, defaults, domains and the primary-key flag — beside an empirical cross-check over real recent technician-days: how many start at 1, how many start at 0, max_lines_in_a_day, and the range. Verdict per_day_from_zero is what this install returns. On the write path the basis is inferred from that technician's own nearby rows: groups starting at 0 mean day0, numbering a day 0,1,2 (tested first, and decisive on a single group — a group whose minimum is 0 is neither the start of a 1-based run nor a step in a climbing sequence); all starting at 1 means day, numbering 1,2,3; none starting at 0 or 1 with climbing minima means global; thin or contradictory evidence is a 409 rather than a pick. ?line_no_basis=day0|day|global settles it explicitly.

The rest of the row shape, from the same DDL. ADJ_DATE is a TIMESTAMP written at midnight. START_TIME/END_TIME are TIMESTAMPs, so a span is stored as the day's date plus the time — 2026-09-02 07:30:00, never a bare 07:30. HOURS is NUMERIC(10,4), TYPE CHAR(2), PAID CHAR(1), TECH_BRANCH CHAR(5) — a longer branch code is a 400 here rather than a value the driver truncates. Sys_Reserved, Lop_Id and Lop_Datetime are Auto-IT's own audit columns and are left to their defaults; this API never writes them.

Three ways to touch a day that already exists. The first two differ in exactly one respect — what happens to a line the body does not mention. ?merge=1 keeps it: the supplied lines are folded into the day, a line whose (type, branch) matches an existing one replaces that line whole, a new key is appended, everything else stays. A day holding AD 9.5, sent [{OT, 0.5}], ends as AD 9.5 then OT 0.5 — LINE_NO 0 and 1. That is the true upsert: it succeeds whether or not the day exists, it never drops a line, and the same body sent twice lands the same day, so a retry after a timeout is safe. ?replace=1 drops it: the day becomes exactly what the body says, which is the only way to remove a line — merge deliberately cannot, because "I did not mention it" and "take it away" must not be the same request. Sending two of the three is a 400 rather than a coin toss.

?patch=1 is the third, and it moves the question down one level — from "what happens to a line you did not mention" to "what happens to a field you did not mention". Each entry in lines names an existing line by (type, branch) and carries only what should change: {"type":"AD","hours":8} makes his Wednesday eight hours and leaves the span, the paid flag and every other line on the day exactly where they were. An absent key means leave it; an explicit null means clear it. That distinction is the whole reason a patch is expressible now and was not before — the old objection was that an omitted start_time is ambiguous between the two, and it is, right up until they are different tokens. ?merge=1 is unchanged and still replaces a matched line whole, so no existing body is reinterpreted.

A patch corrects; it does not create. A line the day does not have is a 409 carrying what the day does have, and a day with no rows at all is a 409 too — use ?merge=1 to enter one. That asymmetry is deliberate: a client patching inside a retry loop must not be able to conjure a roster out of a day nobody entered, because a fabricated available-hours row is a capacity denominator nobody will question. {"type":"OT","delete":true} removes a single line and leaves the rest of the day alone — the one thing ?replace=1 could only do by restating a day somebody else may have added to since. A delete entry carrying any other field is a 400: "remove this" and "change this" in one entry is not a request with one meaning. type and branch are the identity and cannot themselves be patched; moving hours from AD to OT is a different line, so it is a ?replace=1. hours cannot be cleared — send 0. Underneath, the day is rewritten exactly as the other two modes rewrite it, in the same transaction, so LINE_NO stays contiguous and replaced still reports every row that was there; each returned line carries patch: kept | updated.

The identity of a line within a day is (type, branch), not LINE_NO. That is what makes a merge expressible at all: LINE_NO is allocated by the endpoint, so naming a line by it would mean reading the day back and holding a number between two requests — the read-modify-write the whole-day model exists to avoid. type is what a human means ("add overtime", "his normal day was eight not nine"), and branch is in the key because a day split across two branches is two lines, not one line changing its mind. A matched line is replaced whole, not patched field by field — a patch would make an omitted start_time ambiguous between "leave it" and "clear it". Both modes rewrite the day underneath, so replaced always reports what was there and every returned line carries merge: kept | updated | added.

Doing neither is the default. If the day already has rows and neither flag was sent, the answer is 409 Conflict carrying them, and nothing is written. That default follows from the first line of this section: availability is the denominator of every efficiency, utilisation and capacity figure in the workshop, so a delete that quietly removed a day Auto-IT had entered would move all of them by the same invisible factor, and the removed row is recoverable from nowhere in this API. Whatever was there comes back under replaced on a successful write in either mode, so the previous state is at least in your log. A client that only ever adds to a day should send ?merge=1 and will never see this 409 — which is the point of having it. The flag not to reach for out of habit is ?replace=1.

Nothing is inferred from the TYPE code, on the way in either. type is checked for shape — one or two characters, letters and digits — and written as given, upper-cased. It is not checked against a known list: AD and PH are the two confirmed, and rejecting everything else would leave this unable to enter the leave code your install actually uses. ?known_types_only=1 opts into checking against the codes ?types=1 reports. paid is Y or N or omitted — never y, 1 or true, because a flag with three spellings of yes is read differently by every consumer of hours_paid. hours is capped at 24 as a typo guard: 80 in an hours field is a slipped decimal, and it would land in a denominator and stay there.

The time columns are probed, not assumed. START_TIME/END_TIME may be a TIME or a TIMESTAMP depending on the install, and a bare 07:30 bound into a timestamp column lands on 1900-01-01 — a row nobody would go looking for. So the catalog is asked for the domain and a bare time is composed with the day's date when the column is a timestamp; meta.time_basis reports what it bound. Send a full YYYY-MM-DD HH:MM:SS to sidestep the question entirely. branch is TECH_BRANCH — where the hours were available, which is not necessarily the home branch; omitted, it defaults to WKMECHFL.BRANCH and says so in meta.branch_basis, and if the master has no branch either it refuses rather than writing a blank no capacity board can attribute.

The delete and the inserts are one transaction. A day left half-rewritten is worse than one not written at all, because it still looks like a roster. This is the first write in the API to need one, so it releases autocommit around the plan and restores it on every exit path including the exception one. If the driver will not give up autocommit it says so in meta.transactional rather than pretending, and a caller that needs the guarantee can stop there.
KeyTypeRequiredDescription
probeflagopt1 = report WKMECHADJ's key and column shape on this install and write nothing. Run this before anything else.
dry_runflagopt1 = return the whole statement plan — the DELETE and every INSERT with its bound params — and commit nothing.
mergeflagopt1 = fold the supplied lines into the day, keeping everything you did not mention. A line matching an existing one on (type, branch) replaces it whole; a new key is appended. Succeeds whether or not the day exists. Mutually exclusive with replace.
replaceflagopt1 = make the day exactly this body, dropping any line it does not mention. The only way to remove a line. Without merge or replace, an occupied day is a 409 carrying the existing lines.
patchflagopt1 = correct named fields of lines that already exist, keeping every field you did not send. Each entry names a line by (type, branch); an absent key is left alone, an explicit null clears it, and {"delete": true} removes that one line. A line the day does not have — or a day with no rows — is a 409: patch corrects, merge creates. Mutually exclusive with merge and replace.
line_no_basisstringoptday0 (number the day 0..n-1 — what this install does) | day (number it 1..n) | global (MAX(LINE_NO)+1 across the table). Omit to let the endpoint infer it and refuse when the evidence is thin — see the warning above.
time_basisstringopttime | timestamp. Only consulted when a line carries a bare HH:MM.
known_types_onlyflagopt1 = reject a type not already present in WKMECHADJ on this install. Off by default so a new leave code can be entered.
allow_unknown_technicianflagopt1 = write for a code that is not on WKMECHFL. Off by default: history legitimately holds retired codes, but creating a new orphan is almost always a mistyped code.
debugflagopt1 = per-query timing / size in a _debug block.

Body (JSON). One day: tech_code · date (YYYY-MM-DD, and a real calendar date) · branch (optional, see above) · lines[], each carrying type and hours, optionally paid, start_time, end_time and its own branch for a day split across two. Many days: {"days": [ …that same object, repeated… ]}. Maximum 20 lines per day — counted after a merge — and 200 days per request. Columns outside TYPE HOURS PAID START_TIME END_TIME TECH_BRANCH are never written; TECH_CODE and ADJ_DATE come from each day's top level only, so a line cannot quietly retarget itself at another technician.

set_by names the person who saved the day — and it goes in the BODY, not on the query string. ServiceConnect reported every roster row they had ever written landing with insId/updId of dba_za, and they were right about the cause: this write never set CV_USERNAME, so the trigger on WKMECHADJ stamped the account this API connects as. It now does, and reports meta.operator and meta.cv_username_set. It is OPTIONAL here, unlike on the two attribute writes, and the difference is deliberate rather than an oversight: this endpoint has been writing rosters for months from callers nobody controls, and making it required would have refused every one of them at the moment of deploy. Absent, the row is stamped by the connection exactly as before and meta.operator_note says so on the response rather than leaving it to be noticed six weeks later. Over 15 characters is a 400 naming the limit and never a truncation — an operator nobody can resolve is worse than no operator at all.
json — add 0.5 OT to a day that already has AD (?merge=1)
{ "tech_code": "KEVIN MOKALA", "date": "2026-09-02", "branch": "200",
  "lines": [{ "type": "OT", "hours": 0.5, "paid": "Y" }] }
// the existing AD line is KEPT at LINE_NO 0; the OT line is appended at LINE_NO 1.
// send the same body again and nothing changes — the OT line is matched and rewritten.
json — his Wednesday was eight hours, not nine (?patch=1)
{ "tech_code": "KEVIN MOKALA", "date": "2026-09-02",
  "lines": [{ "type": "AD", "hours": 8 }] }
// start_time, end_time and paid are NOT sent, so they are left exactly as they are.
// send "paid": null to clear it instead. Any other line on the day is untouched.
// { "type": "OT", "delete": true } in the same array would drop the OT line.
json — a whole shift, one call
{ "days": [
  { "tech_code": "KEVIN MOKALA", "date": "2026-09-02", "branch": "200",
    "lines": [{ "type": "AD", "hours": 0, "paid": "Y",
                "start_time": "07:30", "end_time": "17:00" }] },
  { "tech_code": "Vusi Nkosi",   "date": "2026-09-02", "branch": "205",
    "lines": [{ "type": "AD", "hours": 9.5, "paid": "Y",
                "start_time": "07:30", "end_time": "17:00" }] }
] }
// each row lands as LINE_NO 0, ADJ_DATE 2026-09-02 00:00:00,
// START_TIME 2026-09-02 07:30:00, END_TIME 2026-09-02 17:00:00
json — a batch
{ "status": "ok", "days_written": 2, "lines_written": 2,
  "hours_written": 9.5, "rows_deleted": 1,
  "days": [
    { "tech_code": "KEVIN MOKALA", "date": "2026-09-02", "branch": "200",
      "lines_written": 1, "hours_written": 0.0, "rows_deleted": 1,
      // what was on the day before — keep it, it is recoverable from nowhere else
      "replaced": [{ "line_no": 0, "type": "AD", "hours": 9.5, "paid": "Y" }],
      "lines": [{ "line_no": 0, "type": "AD", "hours": 0.0, "paid": "Y",
                  "start_time": "2026-09-02 07:30:00", "end_time": "2026-09-02 17:00:00" }] },
    // under ?merge=1 each line also carries "merge": "kept" | "updated" | "added",
    // and the day carries "merged": { "kept": 1, "updated": 0, "added": 1 }
    { "tech_code": "Vusi Nkosi", "date": "2026-09-02", "branch": "205",
      "lines_written": 1, "hours_written": 9.5, "rows_deleted": 0, "replaced": [],
      "lines": [{ "line_no": 0, "type": "AD", "hours": 9.5, "paid": "Y",
                  "start_time": "2026-09-02 07:30:00", "end_time": "2026-09-02 17:00:00" }] }],
  // the basis is decided PER DAY — one technician's history can settle it where the next one's cannot
  "meta": { "days": 2, "date_from": "2026-09-02", "date_to": "2026-09-02",
    "per_day": [{ "index": 0, "tech_code": "KEVIN MOKALA", "line_no_basis": "day0",
      "line_no_evidence": "all 18 of this technician's dated groups within 45 days of 2026-09-02 start at LINE_NO = 0…",
      "time_basis": "timestamp", "branch_basis": "supplied" }, /* … */],
    "transactional": true } }
POST workshop_technician_attendance.php ?set_attribute=1 &dry_run=1

One attribute against one attendance line on WKMECHADJAttribute — the side-table hanging off the technician-day this file owns, keyed (TECH_CODE, LINE_NO, ADJ_DATE, attrType). It is the attendance-grain sibling of Set an RO Attribute, and it lives here rather than on workshop.php because the line key is meaningless without the LINE_NO allocation rules this file already carries. It upserts, and that is what to use for a clock-on position — one LocationOn per line, so a phone replaying a queued write files nothing twice. For a position that legitimately repeats within a day, such as a tracking start/stop pair, use the appending write beside it, which allocates a trip number. Sending the same fact through both writes it twice.

It was built for the location pair the workshop app records: LocationOn and LocationOff, each holding "lat,lng" or "lat,lng,accuracy" — where a technician was standing when they clocked on and off.

This is location data about a named person. Under POPIA that needs a lawful basis, a stated purpose and a policy the technicians have actually seen — and this endpoint is a place to put it, not permission to collect it. Those conditions belong on the screen that gathers it. Two things are enforced here regardless: it stores a point, not a trail — one position per attendance line, written when the technician acts, with no shape that can accumulate a track by accident — and the coordinate is validated, so a LocationOn that is not a real latitude and longitude is refused rather than becoming a row nobody can tell from a technician genuinely at 0,0.
It UPSERTS, where the RO note write APPENDS — and the difference is deliberate. A repair order carries as many notes as somebody types, so a second note there must not replace the first. This is the other case, and Auto-IT's own rows are the evidence: seqNo is NULL on every one of them and a line carries at most one LocationOn. A technician does not clock on twice on the same line — clocking on again allocates a NEW LINE_NO, and therefore a new attribute row. So a repeat here is a correction, not a second fact. That also makes it safe to replay: the phone queues writes and a workshop has dead spots, so the same position may arrive twice — an appending endpoint would file it twice and any report counting positions would be wrong. Sending the same value again is a no-op that moves no stamp.
Every column on this table is nullable, which is not an invitation. Unlike wkRoFileAttribute, where nine columns are NOT NULL and force the caller to name an operator, this DDL declares nothing required — a row could legally be written with no technician, no date and no author. tech_code, line_no, date, attr_type, set_by and source are required here anyway: an audit column that is null on half the rows answers nothing on any of them, and the first person to ask who recorded a technician's position would have nowhere to look. Auto-IT fill them too — insId on their own rows is the technician's code.

SET CV_USERNAME runs before the write, so the DMS's own defaults and triggers stamp the technician rather than the login this API connects as. On the RO attribute table a row written without it landed with insId = dba_za even though the column was bound explicitly — a trigger replaces a supplied value, and only this gets ahead of it. cv_username_set on the response says whether it took.

KeyTypeRequiredDescription
set_attributeflagreq1 — the mode gate. A POST here without it is the technician-day upsert; the two write different tables and neither is inferred from the body.
dry_runflagopt1 = the statement, its bound params and the before → after. Commits nothing.
tech_codestringreqBody. CHAR(15).
line_nointegerreqBody. Which attendance line on the day — allocated by the day write on this file and starting at 0. See the LINE_NO section at the head of the file.
datedatereqBody. YYYY-MM-DD, written to ADJ_DATE at midnight, which is where Auto-IT put it.
attr_typestringreqBody. Any name, 15 chars. LocationOn and LocationOff carry a coordinate check; everything else is stored as sent.
attr_codestringoptBody. Nullable on this table and null on Auto-IT's location rows, so optional here — unlike the RO endpoint, where it is part of a key this table does not have.
child_codestringoptBody. VARCHAR(100).
attr_valuestring|nullreqBody. VARCHAR(8192). Explicit null deletes the attribute; absent is a 400.
set_bystringreqBody. The person — the technician code. CHAR(15), written to insId on an insert and updId on an update.
sourcestringoptBody. Which system wrote it — Auto-IT use DmsApi. Defaults to this route.
{ "action": "written", "written": true, "affected": 1, "id": 5488,
  "key": { "tech_code": "9203924", "line_no": 59, "date": "2026-09-04",
           "attr_type": "LocationOff", "attr_code": null, "child_code": null },
  "before": null, "after": "-25.7778719,29.5315063,41.35",
  "operator": "9203924", "source": "Service Connect Tech",
  // did SET CV_USERNAME take? If false and the operator on the row is
  // wrong, that is the first thing to look at
  "cv_username_set": true }

// the phone replayed a queued position — no second row, no stamp moved
{ "action": "unchanged", "written": false, "affected": 0, /* … */ }
POST workshop_technician_attendance.php ?set_attendance_attribute=1 &dry_run=1

Appends one attribute row against an attendance line, keyed (tech_code, line_no, date), with a trip number allocated in seqNo. It is the job-line write one grain over: that one hangs a fact off a job on a repair order, this one off a spell of a technician's day. It is not linked to a branch or an RO — a body carrying branch, ro_number or job_code is refused by name, because a caller sending those has the wrong endpoint open. And this table has no SEQ column, so the whole SEQ-versus-seqNo question its sibling had to settle does not arise: the trip number goes in seqNo and nowhere else.

Two writes hit this table and you must pick the right one. ?set_attribute=1 upserts — one LocationOn per attendance line, which is the truth for clocking, because a technician who clocks on again gets a new LINE_NO and therefore a new row anyway. Its idempotence is load-bearing: a phone queues writes, a workshop has dead spots, and the same clock-on position gets sent twice; under an upsert the second send is a no-op. This mode appends, for a position that legitimately repeats within one day — the tracking start/stop pairs the technician app produces, which happen as many times as somebody drives. A clock-on position goes through the upsert. A tracking pair goes through this one. Sending the same fact through both writes it twice and nothing here can detect it: the rows are legitimately different shapes.

It is not idempotent, and that follows from appending. A replayed queued write files a second row. The upsert beside it cannot have that problem and this one cannot avoid it — the trip counter is what makes the rows distinguishable — so a client that queues owns not sending the same one twice. There is no delete: "attr_value": null is a 400 here, where on the upsert it clears the row. That one corrects; this one records.

The trip number is scoped to the technician-day, not the line. seqNo is MAX + 1 over (TECH_CODE, ADJ_DATE), read inside the transaction the INSERT then joins. Not per line, for two reasons and the second decides it: LINE_NO is renumbered by the day write on every save — which is why that write refuses a caller-supplied one — so a counter keyed on it would be keyed on a number that moves; and a pair provably spans two lines on this install: on 2026-09-02 technician 9203924 carries a LocationOff on line 55 and a LocationOn on line 56, written ten seconds apart. A row that closes a pair must send back the seq_no its opening row returned — it is not guessed, because the obvious guess is wrong exactly when two things are in flight. The number is checked and reported as pair.matched_on, and a miss is not refused: a close whose open never landed is still a fact.

Refused: no such attendance line (404, and no override flag on either write here — write the day first, then hang attributes on the lines it reports back); a value that is not a coordinate on a Location* type (400); a name with a space in it (400); an over-length value (400 naming the column). The technician is not checked against WKMECHFL, unlike the two writes on the clocking file — not an oversight: requiring an attendance line for this technician on this day is the stronger statement, and a technician with a worked day is a technician.

An unrecognised query key on a POST to this file is a 400. The default write here is the day upsert, which deletes a technician-day's lines and re-inserts them — and until now an unknown mode flag reached it. Nobody has hit that on this file; it is fixed because the file next door showed in production what an ignored flag is worth. The cost: a POST carrying a read parameter now gets a 400 naming the key. GET is untouched.
KeyTypeRequiredDescription
set_attendance_attributeflagreq1 = the mode gate. Without it, and without ?set_attribute=1, a POST here is the day upsert.
dry_runflagopt1 = the INSERT, its bound params and seq_allocation.would_take. Writes nothing, and unwinds the transaction the allocation opened.
debugflagopt1 = per-query timing / size in a _debug block.

Body (JSON). Required: tech_code (technician_code accepted) · line_no · date (YYYY-MM-DD) · attr_type · attr_value · set_by · source. Optional: attr_code · child_code · seq_no (required on a type that closes a pair, i.e. LocationOff). source is required here where the upsert defaults it — several systems append to one technician-day, and a default would make every row answer "who wrote this" with the name of the door it came through. Needs the WRITEKEY header.

json — tracking starts
{ "tech_code": "9203924", "line_no": 56,
  "date": "2026-09-02",
  "attr_type": "LocationOn",
  "attr_value": "-25.8100464,29.4635758,57.01",
  "set_by": "9203924", "source": "Service Connect Tech" }
// the response returns seq_no — the trip number. Keep it.
json — tracking stops, closing THAT trip
{ "tech_code": "9203924", "line_no": 56,
  "date": "2026-09-02",
  "attr_type": "LocationOff", "seq_no": 1,
  "attr_value": "-25.8101,29.4640",
  "set_by": "9203924", "source": "Service Connect Tech" }
// line_no may differ from the opening row's — the pair is matched on
// seq_no, which is scoped to the technician-DAY for exactly that reason.
json
{ "status": "ok", "action": "appended", "written": true,
  "affected": 1, "id": 5453,
  "row": { "tech_code": "9203924", "line_no": 56,
    "date": "2026-09-02", "attr_type": "LocationOn",
    // the trip number — the one thing here you could not have known
    "seq_no": 1, "attr_code": null, "child_code": null },
  "attr_value": "-25.8100464,29.4635758,57.01",
  "operator": "9203924", "source": "Service Connect Tech",
  "cv_username_set": true,
  // what insId ACTUALLY holds, read back rather than assumed
  "stored_operator": "9203924",
  "seq_allocation": { "seq_no": 1, "previous_max": 0,
    "scope": "(TECH_CODE, ADJ_DATE) — every attrType on this technician-day shares the counter…",
    "transactional": true },
  "meta": { "grain": "one row, appended — this mode never upserts and never deletes",
    "measured": false } }
GET workshop_technician_attendance.php ?attributes=1 &tech_code= &date=

The attributes on a technician-day — the read half of Set an Attendance Attribute and of Append an Attendance Attribute. An ordinary read: no write key. Every row carries seq_no, the trip number the appending write allocates — null on a row the upsert wrote and on Auto-IT's own, which is how the two shapes are told apart on a day that carries both — and a trips block lists one entry per trip number: a trip carrying one row has not been closed.

Keyed on the DAY, not the line. A caller asking where a technician was on Friday does not know which LINE_NO the clock-on landed on, and making them ask twice would be an API insisting on its own filing system. ?line_no= narrows it when they do know. Rows come back ordered by LINE_NO, so a day worked in two spells reads in the order it happened.

Every attrType on the day is returned, including rows this API did not write — a read that hid them would report one attribute where the table holds three, and it is also the cheapest way to see what Auto-IT already store against a technician-day on your install.

KeyTypeRequiredDescription
attributesflagreq1 — the mode gate. It short-circuits before the roster's own date window, which would otherwise apply a default fortnight to a lookup that names its own day.
tech_codestringreqOne technician.
datedatereqYYYY-MM-DD. Matched as a half-open range on the bare ADJ_DATE column, not DATE(ADJ_DATE) =, for the reason at the head of this file.
line_nointegeroptNarrow to one attendance line.
attr_typelistoptComma list — e.g. LocationOn,LocationOff.
debugflagopt1 = the SQL and timing block.
{ "meta": { "tech_code": "9203924", "date": "2026-09-04", "count": 2,
           "constrained_types": [ "LocationOn", "LocationOff" ] },
  "data": [
    { "id": 5482, "tech_code": "9203924", "line_no": 59,
      "date": "2026-09-04", "attr_type": "LocationOn",
      "attr_code": null, "child_code": null,
      "attr_value": "-25.8096407,29.4637208,3.32",
      "source": "Service Connect Tech",
      "created_by": "9203924", "created_at": "2026-09-04 17:33:44.423",
      "updated_by": "9203924", "updated_at": "2026-09-04 17:33:44.423" } ] }
POST workshop_technician_attendance.php ?open_line=1  ·  ?close_line=1  ·  GET ?open_lines=1

Attendance written as it happens, one line per spell. (2026-09-10.) A technician presses On at 07:30 and a WKMECHADJ line is opened there and then — END_TIME null, HOURS 0. He presses Break at 13:00 and that same line is completed carrying 5.5 hours. He presses On again at 14:30 and a second line opens on the same day. The break is the gap between two lines, not a line of its own — no new TYPE code is invented, and the day reads as two spells with a gap belonging to neither.

The whole point is that the DMS is true during the day rather than only at knock-off: a foreman can answer "is he in today" from the DMS instead of inferring it from clocking lines, and a technician whose phone dies at four o'clock leaves an open line saying he was here from 07:30 rather than no line at all.

"The open line" is the whole addressing scheme, and that is why the refusals are refusals. ?merge=1 and ?patch=1 address a line by (TYPE, TECH_BRANCH) and cannot tell two AD lines apart — a design question this file still carries. These two modes route around it rather than answering it: a day has at most one line with END_TIME empty, so an open line needs no ordinal to identify it. The endpoint finds it, the caller never sends a LINE_NO, and the rule that LINE_NO is ours to allocate is untouched. Which means the endpoint has to enforce "at most one open line" rather than hope for it: 409 on opening while one is already open (carrying its LINE_NO and START_TIME), 409 on closing when there is none, and 409 on finding more than one — never a guess, because it means something upstream is wrong and seeing it is worth more than a write that picks one. The double-open 409 earns its place on a workshop wifi: a phone retries, and the honest failure of a retried clock-in is "you are already on, since 07:30" rather than a second line nobody meant.
HOURS is 0 while the line is open — a decision, with a price. The column is nullable (HOURS NUMERIC(10,4); the two "the column is NOT NULL" messages elsewhere in this file are an API rule of our own, not a constraint), so a null open line was always possible. 0 was chosen deliberately, and it costs nothing to read back because every read of WKMECHADJ.HOURS in this API is already ISNULL(HOURS, 0) — nine sites across this file and Capacity & Load. Null and zero are indistinguishable downstream. The price, stated so it is not discovered later: an open line is now a real attendance line reading zero hours, and nothing else in this API filters on END_TIME — not one predicate, anywhere. So the only thing that tells "he is on the clock right now" from "he was here and did nothing" is ?open_lines=1. That read is not a convenience; it is the other half of this decision. Capacity is not made wrong by it: today a technician who has not clocked off has no row, so availability reads 0; now he has an open line worth 0, so it still reads 0 — and then accrues through the day as each spell closes instead of arriving all at once at knock-off.
An open spell is a START without an END — not merely a missing END_TIME. (Found in live data on day one, fixed 2026-09-10.) The day upsert writes lines with both times null whenever a caller sends hours and no span — its rule is "supply both or neither" — and a real row on this install reads HOURS 0.5300, START_TIME (null), END_TIME (null). That is a provisional job-clock merge, not a clock-on; HOURS 0.53 is the fingerprint, because ?open_line=1 writes a literal 0 and has no path that can produce it. Testing END_TIME alone made that row indistinguishable from a technician on the clock and broke it both ways: the read listed every hours-only roster line as still on the clock, and ?open_line=1 refused the clock-on with a 409 believing a spanless line was a spell. The test is now START_TIME IS NOT NULL AND END_TIME IS NULL, and spanless lines are counted rather than ignored — spanless_lines on both writes and the read, with a sample under spanless, because a day accumulating them is a day something else is writing to. That last part cannot be defended from this side: a day upsert DELETEs the whole technician-day and re-inserts it, so a provisional merge firing after a clock-on does not just add a confusing row — it destroys the open spell.

We compute HOURS, you do not send it. From START_TIME to the end_time you post, rounded to 2 decimals to match every other hours figure this API emits even though the column is NUMERIC(10,4). One implementation of a payroll number, on the side that owns the column — two systems computing it independently eventually disagree by a minute over rounding, and the person who has to explain that is a service manager who wrote neither. meta.hours_computed reports it. The UPDATE also carries AND END_TIME IS NULL, so two callers closing the same spell cannot both succeed: the loser gets a 409 saying something else closed it, rather than silently overwriting the winner's time.

LINE_NO: the first line of a day is 0. Allocated through the same attwLineNoBasis() the day upsert uses, so all three writes agree by construction, and returned on every open so a client's stored line number and a support call mean the same row. A second spell takes max(LINE_NO) + 1 on the day — contiguous from whatever is already there, which is what cannot collide. GET ?open_lines=1 defaults to the last 7 days, not today: today answers "who is on site" and the week answers "what did we forget". Every row carries minutes_open on the database clock and stale — dated before today, so nobody is still working it. Nothing auto-closes. An open line at 21:00 is evidence, and closing it at a made-up time destroys exactly the conversation it makes possible; "midnight" and "the last known punch" are different claims and only one is defensible to the person whose pay it is.
KeyTypeRequiredDescription
open_lineflagreq1 — the mode flag. POST, WRITEKEY required. Mutually exclusive with merge / replace / patch and with close_line; an unrecognised flag on a POST is a 400 naming it, never a fall-through to the day upsert.
close_lineflagreq1 — completes the day's one open line. Same exclusivity.
open_linesflagreq1 — the read. GET and the two read keys; POSTing it is refused, because a caller sending it on a POST has the wrong verb and should be told so.
technician_codestringreqBody field on both writes. WKMECHFL.Code, max 15 characters. tech_code is accepted as an alias. Also a comma-list query filter on ?open_lines=1.
datedatereqBody field on both writes — YYYY-MM-DD, the day the attendance is for. On ?open_lines=1 it is a query param narrowing the window to one day.
start_timestringreqopen_line only. HH:MM, HH:MM:SS or a full YYYY-MM-DD HH:MM:SS. An open line is a start with no end, so the start is the whole of what it records — a missing one is a 400.
end_timestringreqclose_line only. Same formats. Must be after the open line's start_time or it is a 400: a clock-off before the clock-on is a correction, not a close.
typestringreqopen_line only. The 1–2 character WKMECHADJ.TYPE — AD for a normal working day on this install. Required, not defaulted: the domain of this column is an install convention and not something this API owns, so defaulting one would be the endpoint inventing a payroll code.
paidstringreqopen_line only. Y or N.
branchstringoptopen_line body: TECH_BRANCH, where the hours were available — not always the technician's home branch. Falls back to WKMECHFL.BRANCH; a 400 if neither exists. Also a comma-list filter on ?open_lines=1.
positionobjectoptBody field on both writes — the technician's GPS position at the press, written as XML into WKMECHADJ.Sys_Reserved on the same line, in the same call. { latitude, longitude, accuracy_m, when }; flat latitude/longitude at the top level also works, and so does a bare string "-25.8344623,28.1432047,80". A document is not accepted — send numbers and this composes the XML, which is what makes it recognisable later and therefore mergeable. Bounds-checked; a half or out-of-range coordinate is a 400 and the line is not written.
whenstringoptThe moment of the press, which is not the clocked time: a clock-on recorded as 07:30 may have been pressed at 07:30:04, and the coordinate belongs to the press rather than to the rounded figure that reaches payroll. Omit it and the line's own time stands in. Also accepted inside position.
set_bystringoptBody field on both writes. Sets CV_USERNAME so the DMS's own Lop_Id audit column records who did it. Not written to any column directly. operator_set on the response says whether the connection accepted it.
date_fromdateopt?open_lines=1. Default: today − 6 on the database clock.
date_todateopt?open_lines=1. Default today. The range is half-open on the bare ADJ_DATE, so the last day is whole and the index is still seeked.
dry_runflagopt1 on either write — the exact SQL and bound parameters, nothing committed. Run it before the first real call.
allow_unknown_technicianflagoptopen_line. A code absent from WKMECHFL is a 404 unless this is set — history legitimately holds retired codes, but creating a new orphan is almost always a mistyped code.
line_no_basisstringoptday0 | day | global. Only consulted for the first line of an empty day; a second spell always takes max + 1.
time_basisstringopttime | timestamp. Probed from SYSCOLUMN; only needed when the probe cannot read the domain and you are sending a bare HH:MM.
json
// POST ?open_line=1 — 07:30, he clocks on
{ "action": "opened", "written": true, "rows_affected": 1,
  "line": { "line_no": 0, "type": "AD", "paid": "Y",
            "hours": 0, "start_time": "2026-09-10 07:30:00.000000",
            "end_time": null, "branch": "09", "open": true },
  "day": { "line_count": 1, "open_lines": 1, "hours_total": 0 },
  // the allocated LINE_NO — store it, so a support call names the same row
  "meta": { "line_no": 0, "line_no_basis": "day0",
            "time_basis": "timestamp", "hours_while_open": 0 } }

// POST ?close_line=1 — 13:00, he goes on break. HOURS computed here.
{ "action": "closed", "written": true,
  "line": { "line_no": 0, "hours": 5.5,
            "start_time": "2026-09-10 07:30:00.000000",
            "end_time": "2026-09-10 13:00:00.000000", "open": false },
  "meta": { "hours_computed": 5.5,
            "hours_basis": "END_TIME - START_TIME, rounded to 2 decimals by this API…" } }

// POST ?open_line=1 again while one is open — the retried clock-in
{ "error": "Conflict", "nothing_written": true,
  "message": "This technician-day already has an open line. Close it before opening another…",
  "open_line": { "line_no": 0, "start_time": "2026-09-10 07:30:00.000000" } }

// GET ?open_lines=1 — knock-off, and one of them is from last Tuesday
{ "summary": { "open_lines": 4, "open_today": 3, "stale": 1,
              "technicians": 4 },
  "rows": [
    { "technician_code": "9217158", "technician_name": "Joseph Mahlangu",
      "date": "2026-09-10", "line_no": 1, "type": "AD",
      "start_time": "2026-09-10 14:30:00.000000", "hours": 0,
      "minutes_open": 142, "stale": false },
    // nobody is still working this one — a forgotten clock-off
    { "technician_code": "9203924", "date": "2026-09-03",
      "line_no": 0, "start_time": "2026-09-03 07:28:00.000000",
      "minutes_open": 10112, "stale": true }] }

Technician Work

Reads WKMECHWK — technician clocking against job cards, one row per labour line, with the job description and value from WKRODESC and the invoice state from WKOTHSUB. Unlike WIP Labour Detail, which is gated to open work, this is date-ranged over all clocking — a technician's Tuesday does not stop being work because the job invoiced on Wednesday. ?open_only=1 reproduces the WIP gate.

?live=1 is the floor board — one row per technician, either on a job (with the RO, registration and how long they have been on it) or idle (rostered today, not clocked onto anything). It is the only instant in this API; everything else here is a window over what was clocked. It rests on a null FINISH_TIME meaning "still on the job", which no endpoint has verified on a live DMS — so it measures its own rule and warns when the answer cannot be trusted in either direction. Read meta.open_line_rule first.

This one also writes, and it now has four writes told apart by an explicit mode flag. POST with no flag corrects one clocking line — the line the board above shows still running two days later. That one is UPDATE only: never an insert, never a delete, one line per request, behind three locks (invoiced, reversal, distributed) because these rows become invoice lines. Editing a time sets Start_Finish_Edit automatically, because that flag is what tells every rollup how much of a total rests on typed times. ?assign_technician=1 allocates an RO to a technician; ?set_mech_attribute=1 appends a position against a job line. All need the WRITEKEY header; the read half is unchanged. An unrecognised query key on a POST here is a 400 naming it — a typo'd mode is refused rather than falling through to whichever write is the default.

?clock=1 opens a clocking line — the fourth write, added 2026-09-07, and the first thing in this API ever to INSERT into WKMECHWK. One spell, one row: it appends and never upserts, edits or deletes, because correcting a clocking is the write above. SEQ is allocated inside the transaction that inserts, since a phone on workshop wifi cannot win a read-then-insert race. Of the four money columns it derives exactly one — COST_VAL, from the technician's own HOUR_COST_RATE, with the rate and its source on every response so the arithmetic is checkable. SELL_VAL, CALC_SELL_VAL and INVOICE_HRS are left null: a sell rate is plausible from Wk_Rate_Profile but unconfirmed, and SELL_VAL prices on INVOICE_HRS rather than on hours worked, so a stopwatch cannot produce it. The endpoint resolves that rate and reports it under rate_lookup without writing a cent, so deriving it later is a one-line change with evidence behind it. Not yet run against a live database — meta.measured is false.

GET workshop_technician_work.php ?date_from= &group_by= &technician_code= …

Reversals net by default — ?reversals=exclude drops the reversal but keeps the original, which overstates. hours_assigned and hours_sold are the same column either side of distribution: INVOICE_HRS is restated at invoice time with the original kept in OriginalInvoiceHrs, so the two are equal until a line is distributed. WKRODESC is pre-aggregated before joining — a direct join fans out on multi-line descriptions and silently duplicates hours.

KeyTypeRequiredDescription
ratesflagopt1 ⇒ what an hour actually fetches, and nothing else. The per-line rates have been on this feed all along; what was missing is the question a principal asks — we charge R950, the month came out at R780, where did the other R170 go. Two rates, different questions, both on every row: effective_rate = labour_value ÷ hours SOLD, what a billed hour fetched, which measures discounting; and recovery_rate = labour_value ÷ hours WORKED, what an hour you paid for fetched, which measures discounting and productivity together and is always the lower. The gap between them is productivity_pct. A perfect effective rate beside a poor recovery rate is a shop billing full price for two thirds of the hours it pays for. Plus a leakage block: discounted · unsold_hours · zero_value · zero_cost. Invoiced lines only unless you say otherwise — an open job's sell_val is not yet a price anybody agreed to.
rate_bystringoptrates only. branch (default) · technician · job_type · job_code · franchise · month. Sorted worst-recovery-first — the list is for finding where the rate goes.
target_ratenumberoptrates only. The door rate per hour. Never defaulted. Without one the endpoint still runs and reports rate_gap, value_lost and recovery_pct as null rather than measuring against a guess — there is no door rate in the DMS this codebase has ever read, it differs by franchise, and a default one would be the most quotable wrong number this API could produce.
target_rate_by_franchisestringoptrates only. Per-manufacturer door rate as CODE:RATE pairs — e.g. TY:1050,NS:890. The target is blended per line, so a group spanning two franchises gets a correctly weighted target rather than whichever rate seeded the row; target_rate on each row is that blend.
liveflagopt1 ⇒ the floor board — who is on what, right now, and nothing else. One row per technician, each either on_job (with the RO, registration, job and how long they have been on it, in current[]) or idle (rostered today, not clocked onto anything). This is the only instant in the API — every other technician view is a window over what was clocked. Read meta.open_line_rule before the board. "On a job" means an open clocking line (FINISH_TIME IS NULL, not a reversal), and that rests on an assumption about your DMS that nothing here has verified, so the mode measures its own rule and warns both ways: clocking today but no open lines at all means the DMS closes the line as it writes it, and every technician reads idle whether they are at a bench or not; more open lines than a workshop could hold means it never closes them, and the list is capped and flagged rather than returning years of clocking. Open lines are returned whatever day they started, each with minutes_open and a stale flag — a clock left running is a documented live case (WIP Labour found 22 consecutive whole-day lines on one RO), so a board that dropped them would hide exactly the clockings that are wrong. No utilisation percentage: clocked-over-available reads 12% at nine in the morning for a perfectly normal shop, and a figure that is wrong all morning is one people learn to ignore — both numbers are on every row, take the ratio at end of day.
available_typesstringoptlive only. WKMECHADJ TYPEs that count as rostered. Default AD (a normal working day) — the same rule Technician Attendance sets out, so a public holiday gives an empty board rather than a shop full of idle technicians.
open_capintegeroptlive only. Ceiling on open clocking lines, 1–20000. Default 1000 — far above any real floor and far below a scan worth waiting for. Hitting it sets meta.open_line_rule.truncated and is itself the finding: a real workshop has roughly as many open lines as technicians.
group_bystringoptline (default, one row per SEQ) | technician | day | date | ro | job
date_fromdateoptYYYY-MM-DD, inclusive on the clock date. Independent of date_to.
date_todateoptYYYY-MM-DD, inclusive. Set neither for today − 13 → today.
datedateoptPins both bounds to one day
date_basisstringoptclocked (default: DATE_CLOCKED_IN, else START_TIME) | start | finish. The default order matters — Start_Finish_Edit flags lines whose times were keyed by hand, and on those DATE_CLOCKED_IN is what the system recorded.
reversalsstringoptnet (default) | include | exclude | only
technician_codestringoptComma list. mechanic_code is an accepted alias.
branchstringoptRO branch, comma list. branch_exclude removes.
teamstringoptTechnician's team, comma list
ro_numberintegeroptA single repair order — ignores the date range and searches all history, since a drill-down does not know which fortnight the job fell in
job_codestringoptSublet | Repair | Service | raw code. Same labels as WIP Labour Detail, so a tab-wide filter matches both.
job_typestringoptRetail | Fleet | Internal | Warranty | Policy | Sundry | Excess | Project Billing
work_locationstringoptShop | Field (S / F)
work_catstringoptRaw WKMECHWK.Work_Cat, comma list
sourcestringoptRaw WKMECHWK.Source — where the clocking was captured
open_onlyflagopt1 = the WIP gate: uninvoiced job and open RO header
invoiced_onlyflagopt1 = only lines whose job is invoiced
has_delayflagopt1 = only lines carrying DELAY_HOURS
has_reworkflagopt1 = only lines carrying HOURS_REWORK
diagnosticflagopt1 = only Diagnostic_Ind = Y
editedflagopt1 = only lines whose start/finish were hand-keyed
distributedflagopt1 | 0
rodesc_joinstringoptbranch (default) | ro — see the note above
searchstringoptPartial on technician name, RO number, job code or job description
pageintegeroptPage number
limitintegeroptRows per page. Blank = all rows.
json
{ "meta": { "group_by": "technician", "date_basis": "clocked", "reversals": "net" },
  "summary": { "hours_worked": 1243.5, "hours_sold": 1379.25,
    "efficiency_pct": 110.92, "rework_pct": 1.4, "labour_gp_pct": 74.55 },
  "data": [{ "technician_code": "T014", "technician_name": "Sipho Ndlovu",
    "team": "Diagnostics",
    "hours_worked": 7.5, "hours_assigned": 8.25, "hours_sold": 8.25,
    "hours_rework": 0.5, "hours_delay": 0.25, "hours_diagnostic": 0,
    "cost_val": 1050.00, "sell_val": 4125.00,
    "lines": 4, "ro_count": 2,
    // carried on every rollup: how much of the total rests on hand-keyed times
    "reversal_lines": 0, "edited_lines": 1, "distributed_lines": 0,
    "efficiency_pct": 110.0, "rework_pct": 6.67,
    "labour_gp": 3075.00, "labour_gp_pct": 74.55 }] }
POST workshop_technician_work.php ?clock=1 &dry_run=1 &allow_invoiced=1 &allow_large_hours=1 …

Opens one clocking line on WKMECHWK — a technician started work on a job line at one time and stopped at another, and this is the row that records it. Until 2026-09-07 nothing in this API inserted into that table at all: the correction write updates a line and says so in its own 404 — "a clocking line is opened in the DMS". This is that opening, on the file that owns the table.

One spell, one row. A technician clocks on and off a line as many times as the work takes and the sum is the time on that job, so this appends and never upserts. It does not edit or delete either — a clocking correction is a foreman's job, and the correction write is already the endpoint for it. SEQ is allocated inside the transaction that inserts, scoped to (RO_BRANCH, RO_NUMBER, JOB_CODE, JOB_TYPE, MECHANIC_CODE), because a phone on workshop wifi cannot win a read-then-insert race and two technicians on one job at one bench is ordinary.

Read this before trusting any figure on the row. Of the four money columns on a clocking line, this endpoint derives exactly ONE. COST_VAL is HOURS_WORK × WKMECHFL.HOUR_COST_RATE — the technician's own cost rate off the master the roster endpoint already reads — and the rate used and its source ride on every response, so the arithmetic is checkable rather than trusted. A technician with no rate on the master gets cost_val: null and a cost_warning, never a zero: a zero claims the hour was free and somebody's efficiency report would believe it. SELL_VAL, CALC_SELL_VAL and INVOICE_HRS are left NULL, and that is the important half. A sell rate does exist — Wk_Rate_Profile keyed (Profile_Name, Franchise, Labour_Type, Ro_Type), with Wkmech_Cat_Profile mapping a mechanic category to a profile — so a derivation is plausible. It is not confirmed: nothing in this codebase has ever read either table, how a job line resolves to a profile is unverified, and a live row carries SELL_VAL 1581.51 against CALC_SELL_VAL 405.50 with nothing visible explaining the pair. And SELL_VAL is priced on INVOICE_HRS, not on HOURS_WORK — 3.3333 worked against 4.0000 billed, 0.5000 worked against 1.9501 billed — so worked time cannot produce a sell figure even with a rate in hand. What a customer is billed is a service manager's decision and a stopwatch must not set it.

What it does instead: it resolves the rate profile and REPORTS it, under rate_lookup, without writing a cent of it. Clock a few live jobs, compare the reported rate against what the DMS puts on the same job, and deriving SELL_VAL becomes a one-line change with evidence behind it. That is the same measure-before-believing that found the bound-integer substitution, and it is a great deal cheaper than an invoice dispute.

An invoiced RO is refused — 409 — unless ?allow_invoiced=1. That is the opposite conclusion the trip write reaches from the same fact, and both are right: a position is something that already happened, while an hour is money the DMS may already have printed. Adding labour to a job that has been billed is a credit note in the DMS, not an INSERT here — the same line drawn in the correction write and in customer_contact.php.

HOURS_WORK is stored raw to four decimals and is computed from your two stamps, not from a clock here. Live rows carry 1.3333 — eighty minutes exactly — so there is no quarter-hour rounding rule on this install and none is invented. The times are the technician's and never now(): a workshop has no signal in half its bays and a phone queues a clock-off for minutes, so an endpoint stamping its own clock would record time nobody worked. Both started_at and ended_at are required.

Refused: an unknown RO (404); an unknown job line (404 listing the job lines the RO actually carries); a technician not on WKMECHFL (400); ended_at before started_at (400); a zero-length spell (400 — a clocking with no time on it is not a record of anything); a spell over 24 hours (400 unless ?allow_large_hours=1); a job_type that is not one character (400 — send W, not Warranty); and an over-length value naming the column and its limit rather than truncating silently.

No bound integers anywhere in it, and the row is read back by @@identity inside the transaction before the commit. Both are lessons from the RO attribute write of 2026-09-07: this ODBC driver substituted one bound integer for another and filed a row against a repair order that did not exist, and the verification that should have caught it asked for the row by the key it believed it had written. Numerics go into the statement (int)/(float)-cast, injection-safe by construction, and only strings are bound. A refused commit is a 500 carrying the driver's message; a read-back that finds nothing is a 500 saying NOTHING WAS SAVED. Neither is ever a 200. Not yet run against a live database — meta.measured is false. Rehearse with ?dry_run=1.
KeyTypeRequiredDescription
clockflagreq1 = the mode gate. Without it a POST here is the clocking-line correction, which UPDATEs an existing line instead of opening one. An unrecognised query key on a POST to this file is a 400 naming it.
dry_runflagopt1 = the INSERT, its values, the derived HOURS_WORK and COST_VAL, and the SEQ it would take. Writes nothing.
allow_invoicedflagopt1 = open a line on an invoiced RO, which is otherwise a 409. Pass it only if the backfilled spell genuinely predates the invoice and somebody has decided what that means for the bill.
allow_large_hoursflagopt1 = lift the 24-hour ceiling. The ceiling is a slipped-decimal guard, not a policy — and it matters here because workshop_wip_labour.php?risk_rollup=1 reports lines over 100 hours as data corruption.
debugflagopt1 = per-query timing / size in a _debug block.

Body (JSON). All required: branch · ro_number · job_code · job_type · technician_code · started_at · ended_at · set_by. job_type is the raw single character — F, R, P, W — not the label; service_bookings.php returns both as job_type_raw and job_type. set_by is the technician code or the username of whoever saved it, and reaches Lop_Id through CV_USERNAME so the DMS trigger stamps a person rather than the account this API connects as. The columns a phone cannot source are defaulted from live rows — Labor_Type S, Diagnostic_Ind N, Reversal_Ind N, DistributedInd N, Source M. Needs the WRITEKEY header.

json — one spell on one job line
{ "branch": "M0101", "ro_number": 550965,
  "job_code": "Repair", "job_type": "W",
  "technician_code": "T014",
  "started_at": "2026-09-08 08:15:00",
  "ended_at": "2026-09-08 09:35:00",
  "set_by": "T014" }
// 80 minutes -> HOURS_WORK 1.3333, stored raw. Nothing rounds to a quarter hour.
// The stamps are the ones that HAPPENED, never the moment of the call.
json
{ "action": "clocked", "written": true, "affected": 1,
  "line": { "branch": "M0101", "ro_number": 550965,
    "job_code": "Repair", "job_type": "W",
    "technician_code": "T014",
    // allocated INSIDE the transaction that inserts
    "seq": 4 },
  "hours_work": 1.3333,
  // the ONE money figure this endpoint derives
  "cost_val": 260.00,
  "cost_rate": { "hour_cost_rate": 195.00,
    "source": "WKMECHFL.HOUR_COST_RATE" },
  "operator": "T014", "cv_username_set": true,
  "job_line_found_on": "WKOTHSUB",
  "transactional": true,
  // read back by @@identity before the COMMIT, not assumed
  "row_as_written": { "HOURS_WORK": 1.3333, "COST_VAL": 260.00,
    "SELL_VAL": null, "CALC_SELL_VAL": null, "INVOICE_HRS": null },
  // resolved and REPORTED. Nothing here was written.
  "rate_lookup": { "rate": 780.00,
    "source": "Wk_Rate_Profile (Profile_Name, Franchise, Labour_Type, Ro_Type)",
    "note": "Reported, never written. SELL_VAL prices on INVOICE_HRS, not on HOURS_WORK." },
  "meta": { "source": "WKMECHWK, one row per clocking spell",
    "grain": "one row, appended — this endpoint never upserts, edits or deletes",
    "measured": false,
    "note": "SELL_VAL, CALC_SELL_VAL and INVOICE_HRS are NOT written…" } }
POST workshop_technician_work.php ?clock_open=1  ·  ?clock_close=1

A clocking line on the DMS the moment work starts. (2026-09-11.) ?clock=1 takes a finished spell, so nothing reached WKMECHWK until the technician pressed Stop — and a foreman looking at an RO in the DMS saw who was on it, never who is. ?clock_open=1 inserts the row at Start with FINISH_TIME, HOURS_WORK and COST_VAL all null; ?clock_close=1 completes it at Stop, computing hours and cost exactly as ?clock=1 does. The same shape as attendance spells, one table over.

The retry rule, for all three clock writes — and it was wrong until 2026-09-11. One ?clock=1 produced three rows (SEQ 4, 5, 6 on RO 45209). The read-back after the commit used WHERE ID = @@identity; nothing establishes that WKMECHWK has an ID column, so it came back empty — and the endpoint answered 500 "NOTHING WAS SAVED" after a commit that had succeeded. The client resent, as that message invited, and each resend committed under a fresh SEQ. It reads back by the natural key now, and the rule is: a 5xx means the commit did not happen, and only that. A row committed but not confirmable is a 200 with written: true, verified: false and a read_back_error saying why — and must never be resent. A refused commit now rolls back before restoring autocommit, since per the ODBC spec restoring it commits any open transaction.
An open WKMECHWK line is already a state this DMS understands — unlike WKMECHADJ, where one had to be invented. A row with FINISH_TIME null is what a technician clocking on at a DMS terminal produces, and ?live=1 is built on exactly that predicate. Hours are null, not 0 — the opposite of the attendance decision, and both are right: a zero on a clocking line is a claim about labour that becomes an invoice line, and says "finished, took nothing". One open spell per technician, across every line — a 409 names every open row with its minutes_open, whether this API or a DMS terminal opened it. A forgotten one blocks later clock-ons until closed, deliberately: an open spell from Tuesday is evidence somebody forgot.
A cancel is asked for, never inferred. A close with the same ended_at as the stored one is 200 unchanged, so a replay is safe; a different one is a 409 naming both. To remove an open row, send "cancel": true — the spec asked whether ended_at == started_at could mean cancel, and it cannot: at one-second resolution a double-tap produces equal stamps routinely, and an inferred delete would make a real spell vanish. A zero-length close without cancel is a 400 naming the flag. A cancel deletes only a row still open with no hours, both tested in the DELETE's own WHERE, so it can never remove finished labour from somebody's bill.
KeyTypeRequiredDescription
branch · ro_number · job_code · job_type · technician_codebodyreqThe job line, exactly as ?clock=1 takes it. job_type is the raw single character. Together with seq they are the whole key a close addresses.
started_atbodyreq?clock_open=1. YYYY-MM-DD HH:MM:SS — the technician's time, never the server's.
seqbodyreq?clock_close=1. The number ?clock_open=1 returned as line.seq. Lost it? ?live=1&technician_code= lists open rows with theirs.
ended_atbodyreq?clock_close=1, unless cancelling. Must be after the row's start; over 24 h needs ?allow_large_hours=1.
cancelbodyopttrue = delete the open row instead of closing it. Only an open row with no hours.
set_bybodyreqReaches Lop_Id through CV_USERNAME.
dry_runqueryopt1 = the statement and parameters, nothing committed. On an open it rolls back the SEQ transaction it had to begin.
allow_invoicedqueryoptSame escape as ?clock=1. A close is re-checked against the invoice, because the RO may have been invoiced while the spell was open.
POST workshop_technician_work.php ?probe=1 &dry_run=1 &allow_invoiced=1 &allow_reassign=1 …
This is what a POST to this file does when it names no mode. Until 2026-09-06 that included a POST naming a mode this file did not have: an unknown flag fell through to here, and this write then matched the caller's body against the columns it edits. It is now refused — see the attribute write for what went wrong and why the guard is a key allow-list rather than a mode one.

Corrects one clocking line, keyed (branch, ro_number, seq) — the grain ?group_by=line returns. The case it exists for is the one ?live=1 already reports: a technician who clocked onto a job on Tuesday afternoon and went home without clocking off, whose line is still open on Thursday with minutes_open in the thousands. WIP Labour Detail's risk rollup found 22 consecutive whole-day lines on one RO. Until now the client could display that and nothing else. The second case is clerical and just as common: a delay code never entered, a work category on the wrong line, a job clocked under the wrong badge.

UPDATE only — no insert, no delete, one line per request. Technician Attendance upserts whole days because it owns WKMECHADJ; WKMECHWK is not owned here. A clocking line is opened by the DMS when a technician clocks onto a job, SEQ is allocated there, and its INVOICE_HRS and SELL_VAL become an invoice line the moment the job is billed. So: an unknown key is a 404, never an insert. A line that should not exist is reversed — the DMS writes a paired negative line and the pair is the audit trail; deleting the original would leave the reversal pointing at nothing. And there is no batch, because a bulk edit of clocking should have to be done deliberately, one correction at a time, each answered by the line it changed.

Three locks decide what a line will still take. Fields are grouped into classes and the line's state gates them; everything refused comes back as a 409 naming the class and the reason, and a refusal is never partial — the allowed half of your body is not written either. The response carries locks on every write, refused or not, so a client can grey the right fields out before the user types into them.

INVOICED — the job carries a WKOTHSUB.INVOICE_NO. The hours and values are on a printed invoice and in the ledger, so the line is locked entirely by default; ?allow_invoiced=1 unlocks the clerical class only, because a delay code keyed late does not restate an invoice. Hours, values, times and reassignment stay refused with no override: correcting a billed figure is a credit note in the DMS.  REVERSAL — Reversal_Ind = 'Y'. This line is the audit record of an undo and carries the exact negative of what it reverses; editing it breaks the pair that makes ?reversals=net arithmetic rather than guesswork. Clerical only, under ?allow_reversal=1.  DISTRIBUTED — DistributedInd = 'Y'. INVOICE_HRS was restated at invoice time with the pre-distribution figure kept in OriginalInvoiceHrs, and that pair is what makes hours_assigned and hours_sold two different questions. The sold class is refused on it outright.

Editing a time makes it a typed time, and the row says so. Start_Finish_Edit = 'Y' is what the DMS flags on a line whose start or finish was keyed by hand after the event — the read half surfaces it as times_hand_edited on every row and counts edited_lines on every rollup, precisely so a caller can see how much of a total rests on typed times. A correction that did not set it would be a hand-keyed time hiding inside the measured ones. It is therefore set automatically whenever a time column changes. ?hand_edit_stamp=0 suppresses it and there is no good reason to.

HOURS_WORK does not follow the clock. Closing an open line sets FINISH_TIME and nothing else — the DMS's own relationship between the interval and HOURS_WORK is not documented here, and inventing one would write a number into every efficiency ratio in the workshop on an assumption. So the response always reports interval_hours (finish − start, on the values as they will stand after the write) beside hours_worked, warns when they disagree by more than six minutes, and ?recompute_hours=1 sets HOURS_WORK from the interval when you have decided that is what your DMS means. Sending hours_worked yourself is always the unambiguous option. Clearing finish_time re-opens a line, which is legitimate and is reported rather than refused.

Reassignment is behind its own flag. Moving a line to another technician_code moves the hours, the value and the efficiency off one person's record and onto another's, and both technicians' history changes — including months already reported on. It is a real correction (the wrong badge at the terminal), so it is supported, but it needs ?allow_reassign=1 and the target must exist on WKMECHFL. Never writable at all: the key itself, JOB_CODE/JOB_TYPE (the job decides which invoice line the labour lands on), Reversal_Ind (a line does not become a reversal by flag), the four distribution columns, CALC_SELL_VAL, and Source — a correction must not be able to relabel itself as having come from a clock-in terminal.

One statement, so no transaction. Unlike the attendance write, whose plan spans a delete and several inserts, this is a single UPDATE — atomic on its own, and wrapping it would only add an exit path on which the connection could be left mid-transaction. ?probe=1 first. It reports whether START_TIME is a TIME or a TIMESTAMP on your install (a bare 16:45 bound into a timestamp column lands on 1900-01-01, out of every date-ranged query on this feed), whether the catalog agrees that (RO_BRANCH, RO_NUMBER, SEQ) is the key, and which whitelisted columns actually exist. Bare times are refused when the basis cannot be established, never guessed. Not yet run against a live database — meta.measured is false.
KeyTypeRequiredDescription
probeflagopt1 = WKMECHWK's key and column shape and the time-basis verdict, writing nothing. Run this before anything else.
dry_runflagopt1 = the statement, its bound params and every lock decision. Commits nothing.
allow_invoicedflagopt1 = allow the clerical fields on an invoiced line. Hours, values, times and reassignment stay refused with no override.
allow_reversalflagopt1 = allow the clerical fields on a reversal line.
allow_reassignflagopt1 = permit technician_code to change. Off by default: it rewrites two technicians' history.
allow_unknown_technicianflagopt1 = reassign to a code not on WKMECHFL. Off by default — history legitimately holds retired codes, but a new orphan is almost always a typo.
recompute_hoursflagopt1 = set HOURS_WORK from finish − start. Explicit, never automatic.
allow_large_hoursflagopt1 = lift the 24-hour ceiling. That ceiling is a slipped-decimal guard, not a policy.
allow_negativeflagopt1 = permit a negative hours or value. A negative belongs on a reversal line, which this does not edit.
hand_edit_stampflagopt0 = do not set Start_Finish_Edit on a time change. Default is to set it.
stampflagopt0 = do not touch Lop_Datetime, the column the read half returns as modified.
time_basisstringopttime | timestamp. Only consulted when a bare HH:MM is sent.
debugflagopt1 = per-query timing / size in a _debug block.

Body (JSON). branch · ro_number · seq identify the line and are all required — an RO is (branch, ro_number) in this DMS, never ro_number alone. Then any of: clerical work_cat · delay_code · delay_comment · is_diagnostic · work_location; time date_clocked_in · start_time · finish_time; hours hours_worked · hours_rework · hours_delay; money cost_val · rework_val; sold hours_sold · sell_val; reassign technician_code. Times take YYYY-MM-DD HH:MM:SS, YYYY-MM-DD, a bare HH:MM (composed with the line's own date when the column is a timestamp) or null to clear. The derived keys a GET row carries are accepted and ignored; anything else unrecognised is a 400.

json — close a line left running since Tuesday
{ "branch": "M0101", "ro_number": 550965, "seq": 3,
  "finish_time": "2026-09-02 16:45:00", "hours_worked": 4.25 }
// Start_Finish_Edit is set to Y automatically: this is now a typed time, and
// every rollup counts it in edited_lines. Send hours_worked, or ?recompute_hours=1.
json — clocked under the wrong badge
{ "branch": "M0101", "ro_number": 550965, "seq": 3,
  "technician_code": "T022" }
// needs ?allow_reassign=1 — both technicians' hours and efficiency change,
// for every period this line falls in.
json
{ "status": "ok",
  "key": { "branch": "M0101", "ro_number": 550965, "seq": 3 },
  "fields_updated": ["finish_time", "hours_worked"], "rows_affected": 1,
  // on every write, refused or not — grey the right fields out before the user types
  "locks": { "invoiced": false, "invoice_no": null,
             "reversal": false, "distributed": false },
  "interval_hours": 4.25, "hours_worked": 4.25,
  "notes": ["Start_Finish_Edit set to Y because a time column changed: the read half now reports this line as times_hand_edited and counts it in edited_lines."],
  // the committed line, shaped exactly as ?group_by=line shapes it
  "line": { "branch": "M0101", "ro_number": 550965, "seq": 3,
    "technician_code": "T014", "technician_name": "Sipho Ndlovu",
    "start_time": "2026-09-02 12:30:00", "finish_time": "2026-09-02 16:45:00",
    "hours_worked": 4.25, "hours_sold": 4.7, "efficiency_pct": 110.59,
    "times_hand_edited": true, "is_invoiced": false },
  "meta": { "time_basis": "timestamp", "measured": false } }
POST workshop_technician_work.php ?assign_technician=1 &dry_run=1 &touch_ro=1 …

Hands a repair order to a technician — the decision a foreman makes at half past seven for work nobody has touched yet. It is the forward-looking half of this file's subject: the clocking write above corrects what a technician did, this one records who a job is for. Nothing in this DMS recorded it, because WKMECHWK only gets a line when time is clocked — so every other technician link in this API is retrospective, and a car that arrived this morning belongs to nobody. That is exactly the car somebody wants to hand out.

It moved here on 2026-09-06. This write was service_bookings.php?assign_technician=1 and that route now answers 410 Gone naming this one. The body and every query parameter are unchanged — a client updates its URL and nothing else. It moved because an allocation is a statement about a technician and an RO, which is this file's subject; on the booking feed it was a passenger, sharing nothing with that file but the RO number. It is not aliased, because endpoints here deploy file by file and the only way to keep both URLs alive would be a second copy of the write — and two copies drift until two endpoints disagree about who a job is for, with both answers plausible. The READ did not move: every Service Bookings row still carries allocated_technician_code and allocated_technician_name.
This is NOT a DMS work allocation. The row lands on WKMECHWKAttribute under attrType 'Allocation' with SEQ = 1 — the spelling and the value this install uses, both given on 2026-09-06; every comparison on the type folds case, so a row written under the older lowercase spelling still resolves and a reallocation normalises it. Auto-IT does not read that table to decide who a job is for — a foreman standing at a DMS terminal will not see the assignment. It is a real, durable, shared, auditable record that every consumer of this API sees, and the workshop's own screens are not a consumer. That trade was made deliberately; it is stated here, in the endpoint docblock and in meta.note on every response, because it is the kind of thing that is obvious for a month and then is not.

One row per RO, and five outcomes. No row + a technician → allocated. Same technician → unchanged, and nothing is written: a re-render must not move a stamp. A different technician → reallocated. "technician_code": null → unassigned, a real DELETE — safe here in a way it is nowhere else in this API, because the row carries an allocation and nothing else: no hours, no money, no history another feed reads. An empty string is refused rather than read as either, because '' is a code, and a column holding one is a column that will eventually be joined against. The grain is (RO_BRANCH, RO_NUMBER) — a board hands out a car, so JOB_CODE / JOB_TYPE / SEQ are left null rather than filled with a job this endpoint would have had to pick.

if_current_technician is the optimistic lock — the code the card was showing, null when it showed nobody. A mismatch is a 409 carrying the current holder, because two people moving the same card on a shared board is the ordinary case, not the exception. Refused: an unknown RO (404); an invoiced RO (409 — who did the work is history once it is billed); a technician not on WKMECHFL (400, since a typo writes a code no report can resolve and surfaces six weeks later as a technician with no name); a terminated technician (400, ?allow_terminated=1 for a backdated correction); and more than one allocation row on the RO (409 — two answers to "who is this for" is not an answer, and it is not silently resolved). Not rostered that day is deliberately not refused: a foreman moving work to somebody coming in tomorrow is ordinary, and a rule against it would be this API deciding how a shop plans.

The RO's watermark is not touched. An allocation changes no column on WKROFILE, so a service_bookings.php?modified_since= poller will not see an assignment appear — re-read the RO, or pass ?touch_ro=1 and accept that every consumer then re-reads a job whose booking details did not change. The response also does not carry the booking row, which the old route did: that projection lives on the booking feed, and reaching across files for it is the dependency that took workshop_summary.php down once already. Ask service_bookings.php?ro_number= for the refreshed card. Not yet run against a live database — meta.measured is false. Use ?dry_run=1 first.
KeyTypeRequiredDescription
assign_technicianflagreq1 = the mode gate. Without it a POST here is a clocking correction, which takes a different body — the two writes on this file are told apart by the flag, never by what the body happens to contain.
dry_runflagopt1 = the statement, its bound params, and the move it would make (from → to). Commits nothing.
allow_unknown_technicianflagopt1 = allocate to a code that is not on WKMECHFL. Off by default: a typo here is invisible until a report shows a technician with no name.
allow_terminatedflagopt1 = allocate to a technician marked terminated. For a backdated correction; handing tomorrow's work to somebody who has left is not a decision anybody means to make.
touch_roflagopt1 = also stamp WKROFILE.LOP_DATETIME so the RO appears in the next ?modified_since= delta. Off by default — it makes every consumer re-read a job whose booking details did not change, and leaves that column meaning two different things.
debugflagopt1 = per-query timing / size in a _debug block.

Body (JSON). branch and ro_number identify the job and are both required — an RO is (branch, ro_number) in this DMS, never ro_number alone (ro_no is accepted as the alias). technician_code is required and may be null to unassign (mechanic_code is the alias). if_current_technician is optional and is the optimistic lock. Anything else is a 400 listing what it did not recognise: this mode allocates an RO to a technician and writes nothing else. Needs the WRITEKEY header on top of the two read keys.

json — hand the job to a technician, safely
{ "branch": "M0101", "ro_number": 550965,
  "technician_code": "T014",
  // what the card was showing. null = it showed nobody.
  "if_current_technician": null }
json — take it back off them
{ "branch": "M0101", "ro_number": 550965,
  "technician_code": null }
// null unassigns and deletes the row. "" is refused: an empty string is a code.
json
{ "status": "ok", "changed": true, "action": "reallocated",
  "key": { "branch": "M0101", "ro_number": 550965 },
  "reg_no": "CA 123-456",
  "technician_code": "T014", "technician_name": "Sipho Ndlovu",
  "previous": "T022", "rows_affected": 1,
  "sync": { "wkrofile_watermark_touched": false,
    // an allocation changes no column on the RO header, so a
    // ?modified_since= poll will not report it. Re-read, or ?touch_ro=1.
    "note": "WKROFILE's watermark was NOT touched…" },
  "meta": { "source": "WKMECHWKAttribute, attrType 'Allocation', SEQ 1",
    "grain": "one repair order", "measured": false,
    "moved_from": "service_bookings.php?assign_technician=1, which now answers 410 naming this route.",
    "note": "This is not a DMS work allocation…" } }
POST workshop_technician_work.php ?set_mech_attribute=1 &dry_run=1 &allow_unknown_job=1 …

Appends one attribute row against one job line on WKMECHWKAttribute, keyed (branch, ro_number, job_code, job_type, mechanic_code). It exists for the pair the ServiceConnect technician app produces — LocationOn / LocationOff, where a technician was when distance tracking for a job started and stopped — at the RO grain, so the positions sit beside the job rather than only beside the shift, which is where the attendance-grain sibling (workshop_technician_attendance.php?set_attribute=1, on WKMECHADJAttribute) files the same pair. Any attr_type may be written; the two location names are simply the ones whose value is validated as a coordinate, because a latitude of 91 is not a place and junk stored here cannot later be told from a technician genuinely at 0,0.

It appends. It never upserts and it never deletes. A technician drives to a job more than once, so a second LocationOn is a new row — the opposite of the allocation write on this same file, which upserts because an RO is allocated to one person at a time. "attr_value": null is a 400, not a delete: a position that was recorded happened, and a write that can retract it is a write that can lose it. The cost of appending, said plainly: this is not idempotent. A phone replaying a queued position files it twice. The attendance sibling upserts precisely so a replay is a no-op — it can, because a technician clocks on once per attendance line; here there is no such natural key, and a client that queues writes owns not sending the same one twice.

The trip number goes in seqNo, and this endpoint allocates it. The rule: the first LocationOn on an RO takes 1, its matching LocationOff takes 1 as well, the next LocationOn takes 2 — a pair counter per (RO_BRANCH, RO_NUMBER). Not SEQ: that column is the clocking line's own sequence on this table's parent key — this file's own correction write is keyed on (RO_BRANCH, RO_NUMBER, SEQ) — so a trip counter there would not merely be ambiguous, it would collide with an identity that already exists, invisibly, until somebody joined on it. It is written there anyway, on the product owner's decision of 2026-09-06 and over that objection, which is recorded rather than deleted: SEQ takes the same number as seqNo, from one allocation, so the two cannot disagree — and that is why seq is not accepted on the body. The question that was answered before it shipped: nothing in this API joins WKMECHWKAttribute to WKMECHWK at all, on SEQ or anything else, so the collision is real but not live. Anything that ever writes that join must filter attrType — an obligation the allocation row's own SEQ = 1 already created. The MAX is read inside the transaction the INSERT then joins, because read-then-insert is a race and two technicians driving for one RO on one morning is ordinary; if the driver refuses to release autocommit the row is still written and transactional: false says so, because refusing to record where a technician was, over an ordinal, is the worse trade.

A LocationOff must send back the seq_no its LocationOn returned. It is not guessed — the obvious guess, "the highest open trip on this RO", is wrong exactly when it matters, which is two technicians driving to one job. The number you send is checked and reported as pair.matched_on, but a miss is not refused: an Off whose On never landed is still a fact that happened, and dropping it to protect a pairing would lose the more important of the two rows. Lost the number? GET ?mech_attributes=1&branch=&ro_number= returns every row on the RO with a trips block — a trip carrying one row has not been closed.

Refused: an unknown RO (404); an unknown job line (404 listing the job lines the RO actually carries); a technician not on WKMECHFL (400); a value that is not a coordinate on a Location* type (400); a name with a space in it (400 — attrType/attrCode are CHAR(15) and part of how a row is found again); an over-length value (400 naming the column and the limit, never a silent truncation). The job line is checked on WKOTHSUB (the jobs on the card) and then on WKMECHWK — either satisfies it, because this DMS writes a clocking line only when time is booked, and a job nobody has touched yet is exactly the job somebody drives out to. An invoiced RO is accepted, which is the one refusal the allocation write has that this does not: there, who did the work is history once billed; here the row is a fact about something that already happened, and a trip can be stopped after the job was closed. A terminated technician is accepted too — allocating work to somebody who has left is a mistake about the future, recording where they were is a fact about the past.

An unrecognised query key on a POST to this file is now a 400. Reported live on 2026-09-06: a POST carrying ?set_mech_attribute=1 before this mode existed was not refused — it fell through to the clocking-line correction, which matched the body against the columns it edits and rejected three names. Three words are all that stopped a real labour line being edited with a 200, and the next attempt was refused at a different stage for a different reason — so nothing about a caller's body can be relied on to make a fall-through harmless. "The flag is what tells these writes apart, never the body" is only true if an unknown flag stops. The cost: a POST that carries a read parameter now gets a 400 naming the key, where it used to be silently ignored. That is the trade, deliberately.
set_by is required, and the row is read back to check it landed. It is written to insId and updId, and SET CV_USERNAME runs first — because on the RO-grain sibling table a row written with set_by bound to both columns came back reading dba_za, the ODBC login, while an Auto-IT row on the same table carries a real name. A column default cannot overwrite a supplied value; a trigger can. So the response reports stored_operator: what insId actually holds. Unlike the RO endpoint this one does not then issue a corrective UPDATE — that workaround moves the audit stamps on a row that was just created, and these rows are facts written once. Not yet run against a live database — meta.measured is false. ?dry_run=1 reports the statement, the params and the trip number it would take.
KeyTypeRequiredDescription
set_mech_attributeflagreq1 = the mode gate. Without it a POST here is the clocking-line correction, which edits WKMECHWK.
dry_runflagopt1 = the INSERT, its bound params and seq_allocation.would_take. Writes nothing, and unwinds the transaction the allocation opened.
allow_unknown_jobflagopt1 = accept a (job_code, job_type) that is on neither WKOTHSUB nor WKMECHWK for this RO. Reported on the response as job_line_found_on: null.
allow_unknown_technicianflagopt1 = accept a mechanic_code not on WKMECHFL.
debugflagopt1 = per-query timing / size in a _debug block.

Body (JSON). Required: branch · ro_number (ro_no accepted) · job_code · job_type · mechanic_code (technician_code accepted) · attr_type · attr_value · set_by · source. Optional: attr_code · child_code · seq_no (required on a type that closes a pair, i.e. LocationOff). job_code and job_type are the raw DMS values — 'W', not 'Warranty' — as ?group_by=job returns them in job_code_raw / job_type_raw. Anything unrecognised is a 400 listing it. Needs the WRITEKEY header.

json — tracking starts
{ "branch": "62", "ro_number": 547764,
  "job_code": "Repair", "job_type": "W",
  "mechanic_code": "9221930",
  "attr_type": "LocationOn",
  "attr_value": "-25.8765703,28.2015457,13.54",
  "set_by": "9221930", "source": "Service Connect Tech" }
// the response returns seq_no — the trip number. Keep it.
json — tracking stops, closing THAT trip
{ "branch": "62", "ro_number": 547764,
  "job_code": "Repair", "job_type": "W",
  "mechanic_code": "9221930",
  "attr_type": "LocationOff", "seq_no": 1,
  "attr_value": "-25.8801,28.2099",
  "set_by": "9221930", "source": "Service Connect Tech" }
// seq_no is REQUIRED here: the pair is matched on it, and this endpoint
// will not guess which trip an Off closes.
json
{ "status": "ok", "action": "appended", "written": true,
  "affected": 1, "id": 4417,
  "row": { "branch": "62", "ro_number": 547764,
    "job_code": "Repair", "job_type": "W",
    "mechanic_code": "9221930", "attr_type": "LocationOn",
    // the trip number — the one thing here you could not have known
    "seq_no": 1, "attr_code": null, "child_code": null },
  "attr_value": "-25.8765703,28.2015457,13.54",
  "operator": "9221930", "source": "Service Connect Tech",
  "cv_username_set": true,
  // what insId ACTUALLY holds, read back rather than assumed
  "stored_operator": "9221930",
  "job_line_found_on": "WKOTHSUB",
  "seq_allocation": { "seq_no": 1, "previous_max": 0,
    "scope": "(RO_BRANCH, RO_NUMBER) — every attrType on this repair order shares the counter…",
    "transactional": true },
  "meta": { "grain": "one row, appended — this endpoint never upserts and never deletes",
    "measured": false,
    "note": "Auto-IT does not read this table… SEQ is left null deliberately." } }
GET workshop_technician_work.php ?mech_attributes=1 &branch= &ro_number= …

Every attribute row on one repair order, ordered by trip and then by the order they were written — which is the open before the close. It is the read half of ?set_mech_attribute=1 and it shipped with it, rather than after: the RO-grain sibling endpoint shipped its write first and its own docblock calls that what it was, a hole you can put a fact into and never get back. A trip number allocated by the write and lost by the caller is unrecoverable without this.

It returns every attrType on the RO — including the Allocation row the allocation write puts there, and anything the DMS itself ever writes. A read that hid the rows this API did not create would tell a support engineer the RO carries two attributes when the table holds five, which is worse than no read at all. Two columns are easy to confuse and both ride on every row: seq_no is the trip number this API allocates, and line_seq is the DMS column SEQ, which means the clocking line on WKMECHWK and something else on every row written here: the same trip number on a location row, and a fixed 1 on the allocation row. A location row whose line_seq is null was written on 2026-09-06 before that column was populated, and that is the only thing distinguishing the two eras until somebody backfills.

The trips block is one entry per trip number with the types on it: a trip carrying one row has not been closed. Not paginated — the population is bounded by the RO you named. No write key: it is a read.

KeyTypeRequiredDescription
mech_attributesflagreq1 = the mode gate. Short-circuits before any clocking query is built.
branchstringreqRO branch. An RO is (branch, ro_number) in this DMS — a number alone collides across branches.
ro_numberintegerreqThe repair order. This mode reads the attributes on an RO you name; it does not scan the table.
attr_typestringoptComma list, e.g. LocationOn,LocationOff. Omit to see everything on the RO, allocation row included.
mechanic_codestringoptComma list. technician_code is the alias.
debugflagopt1 = per-query timing / size in a _debug block.
json
{ "meta": { "branch": "62", "ro_number": 547764, "count": 3,
    "constrained_types": ["LocationOn", "LocationOff"] },
  // a trip with one row has not been closed
  "trips": [ { "seq_no": 1, "types": ["LocationOn", "LocationOff"], "rows": 2 },
             { "seq_no": 2, "types": ["LocationOn"], "rows": 1 } ],
  "data": [
    { "id": 4417, "branch": "62", "ro_number": 547764,
      "job_code": "Repair", "job_type": "W", "mechanic_code": "9221930",
      // line_seq is the DMS SEQ column; seq_no is the trip number
      "line_seq": null, "attr_type": "LocationOn", "seq_no": 1,
      "attr_value": "-25.8765703,28.2015457,13.54",
      "source": "Service Connect Tech", "created_by": "9221930",
      "created_at": "2026-09-06 08:26:25.188" } ] }

Technician Timeline

A Gantt of each technician's day: attendance on top, the repair orders they clocked onto beneath. One row per technician, with two sets of bars for the window — WKMECHADJ spells (when they were clocked in) and WKMECHWK spells (which job they were clocked onto). Built to be drawn: every bar comes with its offsets from the window's start in seconds, so a chart plots it without parsing a date.

Its own file, for the reason Capacity & Load and Technician Positions are: two owners, one view. Attendance belongs to Technician Attendance and job clocking to Technician Work.

GET workshop_technician_timeline.php ?date= &technician_code= &branch= &include_idle=1 …
An open spell runs to now — and says so on every bar. WKMECHADJ.END_TIME null means the technician is still clocked in; WKMECHWK.FINISH_TIME null means they are still on that job. Either bar ends at meta.now, the database clock — never PHP's, since a server an hour off the DMS would draw every open bar an hour wrong and the error would be exactly the length nobody can check. The substitution is never silent: every bar carries open, end_basis (recorded | now) and end_recorded — the column as stored, null on an open bar — so a bar ending at now can never pass for one ending at a recorded time. That is the line between this and the fabricated fallback CLAUDE.md warns against: an open spell is a presence whose end has not happened, and "up to now" is the only true statement about it, provided it is labelled.
An open attendance spell is a START_TIME with no END_TIME — not merely a missing end. The day upsert writes lines with both times null when a caller sends hours without a span, and "end = now" on one of those draws a bar with no start. They come back under the technician's unplaced list with their hours: counted, not drawn. Bars that cross the window come back twice — start/end as the spell ran, bar_start/bar_end clipped, with clipped saying which edge was cut. A spell opened before the window and still open is fetched (?lookback_days=, default 7) and drawn from the left edge with opened_before_today: true — a fact, not a verdict: a night shift carries it too, and so does a forgotten clock-off.
Where each attendance bar started and ended — clock_in_position / clock_out_position. A clock-in or clock-out writes a WKMECHADJAttribute row, attrType ClockIn / ClockOut, with the GPS position in attrValue; it comes back on the bar as numbers. Joined by time, not by LINE_NO. The two tables share only the technician and ADJ_DATE — the LINE_NO on a position does not correspond to the attendance line it describes, so joining on it would pin line 7's clock-in onto line 1's bar and look right while doing it. A ClockIn is paired with the line whose START_TIME it sits within milliseconds of, a ClockOut with END_TIME; pairs are taken nearest-first across the day, each used once. Every position reports gap_ms, and attr_line_no for diagnosis only. The gap is measured against the line's Lop_Datetime as well as its start/end, because START_TIME/END_TIME come from the phone at one-second precision while Lop_Datetime is the DMS's own server stamp — the true millisecond twin of the attribute's insDt. Positions dated in the window that match no bar go to unmatched_positions; none are dropped.
What the two rows say together. outside_attendance_min on each job bar is the part no attendance bar covers — clocked onto a job while not clocked in, which is either a missing attendance line or a mistyped time. attendance_min and job_min are the union of each set of bars, so overlaps are not counted twice, and job_share_of_attendance_pct is the ratio — deliberately not called utilisation, because Capacity & Load owns that word with a rostered denominator. Bars carry a lane: one lane is normal, more means the data genuinely overlaps and would otherwise hide a bar. Reversal lines are not drawn (summary.reversals_excluded).
What a drag may do — for Timeline Edits. Every bar carries editable, editable_start, editable_end and lock_reason: ro_invoiced (job line or RO invoiced — nothing moves), open_spell (the start may move; the end is a clock-off), and hours_only on unplaced entries. Job bars add invoiced, invoice_no and times_hand_edited; attendance bars add edited — the original clocked times, who moved them and when — plus deletable, delete_requires_allow_open, splittable, merged_from, split and merged_positions (interior clock presses of a merged spell, hung on its bar by a second pass rather than reported unmatched); both carry last_modified_at. The writes make their own refusals; these only stop a drag being offered that cannot succeed.
KeyTypeRequiredDescription
datedateoptYYYY-MM-DD. Default today on the database clock.
date_fromdateoptA multi-day window instead. Max 31 days — a Gantt of a month is already hard to read.
date_todateopt
technician_codestringoptComma list. tech_code and mechanic_code are aliases.
branchstringoptComma list of home branches (WKMECHFL.BRANCH). Selects which technicians appear; all their bars are then shown wherever the time was worked, because hiding the half of a day worked elsewhere draws a gap that is not one.
include_idleflagopt1 = also return active technicians with no bars, so the chart can draw their empty rows.
lookback_daysintegeroptHow far back an open spell may have started and still be drawn. Default 7, max 60.
positionsintegeropt0 = skip the ClockIn / ClockOut GPS join. On by default.
position_match_msnumberoptHow far apart, in milliseconds, a line's start or end and a position's insDt may be and still pair. Default 2000 — the rows are written by two calls a few ms apart, but START_TIME carries whole seconds, so the default absorbs that truncation. Every match reports gap_ms, so it can be tightened from evidence.
clock_in_typestringoptThe attrType of a clock-in position. Default ClockIn; clock_out_type defaults to ClockOut. Compared case-insensitively.
pageintegeroptPages technicians. Every bar of a technician on the page is returned.
limitintegeroptTechnicians per page; blank = all.
debugflagopt1 = per-query timing / size.
json
{ "meta": { "window": { "from": "2026-09-11", "to": "2026-09-11",
      "start": "2026-09-11 00:00:00", "end": "2026-09-12 00:00:00" },
    // every open bar ends here — the DATABASE clock
    "now": "2026-09-11 10:15:30", "measured": false },
  "technicians": [
    { "technician_code": "Andries Mashego", "home_branch": "100",
      "clocked_in_now": true, "clocked_in_since": "2026-09-11 09:35:49",
      "on_job_now": [], "attendance_lanes": 1, "job_lanes": 1,
      "attendance": [
        { "kind": "attendance", "line_no": 1, "type": "AD",
          "start": "2026-09-11 09:15:24", "end": "2026-09-11 09:21:11",
          "open": false, "end_basis": "recorded",
          "from_sec": 33324, "to_sec": 33671, "lane": 0 },
        // END_TIME null — still clocked in, so the bar runs to meta.now
        { "kind": "attendance", "line_no": 7,
          "start": "2026-09-11 09:35:49", "end": "2026-09-11 10:15:30",
          "end_recorded": null, "open": true, "end_basis": "now",
          "hours_recorded": 0, "from_sec": 34549, "to_sec": 36930 }],
      "jobs": [
        { "kind": "job", "ro_number": 45054, "branch": "100",
          "job_code": "Repair 2", "job_type": "I", "job_type_label": "Internal",
          "seq": 2, "start": "2026-09-11 10:03:27", "end": "2026-09-11 10:04:11",
          "open": false, "hours_work": 0.0122,
          "from_sec": 36207, "to_sec": 36251, "lane": 0,
          // inside the open attendance spell above
          "outside_attendance_min": 0 }],
      "unplaced": [] }] }
POST workshop_technician_work.php ?clock_edit=1  ·  workshop_technician_attendance.php ?edit_line=1

Drag a bar, save the corrected time. (2026-09-11, ServiceConnect's SPEC-timeline-edit.md.) Two writes, one per table, each editing one existing row in place: ?clock_edit=1 moves a job bar's START_TIME / FINISH_TIME on WKMECHWK, ?edit_line=1 moves an attendance bar's START_TIME / END_TIME on WKMECHADJ. Both take what the screen was showing as if_* and answer 409 with the current row when it no longer matches — an edit made at a DMS terminal in the meantime is never overwritten silently. Both recompute the hours, and both are idempotent: resending a saved edit answers 200 unchanged.

Why not the existing correction writes. The plain clocking correction names a line by (branch, ro_number, seq) — but ?clock=1 numbers SEQ per (RO, job line, technician), so on any RO two technicians worked "seq 1" is two rows and it refuses with a 409. It also leaves HOURS_WORK alone unless asked and has no stale-read guard. On attendance, every write that can change a closed line — the upsert, ?merge, ?replace, ?patch — deletes and re-inserts the day, renumbering LINE_NO; and ?patch=1 names a line by (type, branch), which a day of three AD spells cannot answer. These two are the first writes that change one closed row and nothing else.
The two differ where the tables differ. Overlap: a job edit that overlaps another job spell is allowed and reported (overlaps) — a technician on two job lines at once is sometimes real; an attendance edit that overlaps another attendance spell is a 409 — clocked in twice at once never is. Locks: a job line or RO that is invoiced cannot move at all (a credit note, not an edit), with no override. Open spells: on both, only the start of an open bar may move; its end is a clock-off (?clock_close=1 / ?close_line=1). Stamps: a job edit sets Start_Finish_Edit = 'Y' and recomputes HOURS_WORK and COST_VAL exactly as ?clock_close=1 does; INVOICE_HRS, SELL_VAL, SEQ are never touched. An attendance edit recomputes HOURS; LINE_NO, type, branch and paid are untouched.
The attendance edit keeps a trail — and that is what keeps the GPS. The timeline pairs a ClockIn / ClockOut position to its line by time, so moving a start by eight minutes would silently unpair it. The first time a field is moved, its clocked value is kept in the line's Sys_Reserved document, beside the clock positions: <edit start="…" end="…" by="…" at="…" n="…"/>. The originals are never overwritten; the timeline adds them to the match, and reports the trail on the bar as edited. It is the only history of the line there is — Lop_Id is an insert default and does not change on an update. The GPS rows themselves are never moved: they record where the phone was when the button was pressed.
A job dragged out of the front of attendance takes the attendance line with it. (2026-09-12.) Move a clocking to 07:40 when the attendance line starts 07:48 and ?clock_edit=1 pulls that line's START_TIME back to 07:40 in the same transaction, recomputing its HOURS — the eight red minutes the timeline would otherwise paint are the attendance line being behind what the work says. The start edge only: a job dragged past the end of the shift still reports outside_attendance_min, because whether that is overtime is a person's decision. It clamps rather than collides: if the spell before it ended 07:45, the line goes back to 07:45 and no further, and says clamped. It never invents an attendance line (TYPE and PAID are payroll codes this API does not default) and never moves a start onto another date. Every outcome — moved, clamped, or not moved and why — is in attendance_adjusted, in the dry run as well. ?adjust_attendance=0 turns it off. The clocked start is kept in Sys_Reserved as <edit start="…"/>, so the ClockIn pin still pairs with the line.
The END edge is a question, never a default — ?attendance_end= (2026-09-12.) A job dragged past knock-off is one of two different payroll statements, and nothing in the data says which: the shift ran late (extend — the covering line's END_TIME moves out, HOURS recomputed) or the extra time is overtime (overtime — a separate line from the old end to the job's end, ?overtime_type=, default OT, carrying the shift line's paid flag and branch). Absent the parameter nothing happens and the timeline goes on reporting outside_attendance_min. Both run in the job's own transaction and clamp at the next spell. The overtime code is checked, not assumed: a TYPE on no WKMECHADJ row is refused with the codes the table does hold — AD and PH are the two with a confirmed meaning here and nothing in this API has ever read an OT row — unless ?allow_new_type=1. Reported as attendance_end_adjusted, same shape as the start edge, in the dry run too.
Which line is "the" line — and it is a different question at each edge. (Corrected 2026-09-12 after a PDI dragged to 17:57 on a day whose shift ended 16:55 found nothing to adjust.) The start edge takes the line the job overlaps, else the first line starting after the job, bounded by ?attendance_near_minutes= (default 120, 0 = no bound) — a shift beginning hours after the job is not the job's shift. The end edge looks the other way: the line the job overlaps, else the last line of the technician-day the job follows, however long after it the job ran, because that is the whole overtime case. It is bounded by the day and the 24-hour check, never by a distance. Either way the search reports itself as searched — the rule, the lines read, the nearest line and its gap — so a declined adjustment never needs a second call to explain itself.
One complete attendance line, on its own — ?add_line=1. The fourth way to create one, and the only one that inserts a finished line without touching the rest of the day: the upsert and ?merge=1 rewrite the day and renumber it, ?open_line=1 opens a spell with no end. type and paid are required — this API does not default a payroll code — the day's next LINE_NO comes back as new_line_no, and HOURS is computed here. An overlap with any existing spell is a 409 carrying overlaps so the page can offer the merge instead, and a resend of the same type over the same times is 200 unchanged rather than a second line — a manager double-clicking Save must not create two.
Two overlapping spells on one job line become one — ?clock_merge=1 (2026-09-12.) The survivor keeps its SEQ and takes the union; the others are deleted; HOURS_WORK and COST_VAL are recomputed, and open wins (a merged spell still running goes back to FINISH_TIME, HOURS_WORK, COST_VAL all null). Same RO and same job line is structural, not a rule: an absorbed row is named by its seq alone and inherits the survivor's key, so a merge across two ROs or two job lines cannot be expressed — it would move labour from one invoice line to another. They must overlap. A technician clocking on and off the same job five times is five legitimate spells, and merging those would bill time nobody worked, so a gap is a 400 naming it; ?allow_gap=1 means it and the response says how many minutes were swallowed. The timeline hands you the list: each job bar carries merge_candidates, the seqs of overlapping spells on its own line.
Merge, break, delete — ?merge_lines=1 · ?split_line=1 · ?delete_line=1 on the attendance endpoint (2026-09-11, SPEC-timeline-edit-2.md). Merge folds the lines in absorb into the survivor: its span becomes the union, open wins, the others are deleted — refused if type, branch or paid differ (a payroll decision, not a guess), and 409 with overlaps if the union would cover a line not in absorb. Break ends the line at break_start and inserts a copy of it from break_end to the original end (or open) under the day's next LINE_NO. Delete removes one line; an open one needs "allow_open": true; nothing is renumbered. Merge and break are one transaction and are refused outright if the driver will not open one. No WKMECHADJAttribute row is re-keyed or deleted: its LINE_NO does not identify the attendance line on this install, so doing it by LINE_NO would move or delete the wrong positions. Positions follow by time instead — a break's ClockOut pairs with the second half because its end is the original's; a merge's interior presses appear on the bar as merged_positions; a deleted line's become unmatched_positions.
KeyTypeRequiredDescription
branch · ro_number · job_code · job_type · technician_code · seqbodyreq?clock_edit=1. The whole natural key — all on the timeline job bar except technician_code, which is the code on the bar's technician row, never the name.
started_at · ended_atbodyone?clock_edit=1. YYYY-MM-DD HH:MM:SS, one or both. Absent = unchanged; null is a 400.
if_started_at · if_ended_atbodyreq?clock_edit=1. What the screen showed. if_ended_at: null = it showed the spell open. Compared to the second.
technician_code · date · line_nobodyreq?edit_line=1. date and line_no are on the timeline attendance bar; line_no is the LINE_NO. tech_code accepted.
start_time · end_timebodyone?edit_line=1. Full stamps. A start moved off the line's own date is a 400 — that is another day's line; an end may cross midnight.
if_start_time · if_end_timebodyreq?edit_line=1. As above; if_end_time: null for an open line.
absorbbodyreq?clock_merge=1. [{ seq, if_started_at, if_ended_at }] — the other spells on this job line. They carry no branch, RO or job code: the survivor's key is theirs. The timeline's merge_candidates on the bar is this list.
attendance_near_minutesqueryopt?clock_edit=1, start edge only. How far forward it may reach to a line the job does not touch. Default 120; 0 = no bound. The end edge is bounded by the technician-day instead.
attendance_endqueryopt?clock_edit=1. extend | overtime | none (default). What to do when the job now ends after the attendance line does. ?overtime_type= (default OT) and ?allow_new_type=1 go with overtime.
type · paid · start_time · end_timebodyreq?add_line=1. All four; branch falls back to the technician's own. A TYPE this install does not use is refused unless ?allow_new_type=1.
allow_gapqueryopt?clock_merge=1. 1 = merge spells that do not overlap, absorbing the dead time between them as billable. Refused without it.
absorbbodyreq?merge_lines=1. [{ date, line_no, if_start_time, if_end_time }] — the lines to fold into line_no. date defaults to the survivor's and may be a day either side. The overlaps list from an ?edit_line=1 409 has exactly these keys.
break_start · break_endbodyreq?split_line=1. Both halves must keep time: start < break_start < break_end < end; on an open line break_end ≤ now.
allow_openbodyopt?delete_line=1. true to delete an OPEN line — which takes the technician off the clock.
set_bybodyreqAll. SET CV_USERNAME; on attendance also the by of the trail.
dry_runqueryopt1 = before / after, the recomputed figures, overlaps and the SQL. What a confirm dialog shows. Writes nothing.
allow_large_hoursqueryopt1 = allow a span over 24 h.
adjust_attendancequeryopt?clock_edit=1. 0 = do not pull the attendance line back to cover a job dragged out of the front of it. On by default — see the panel above.

Technician Positions

Where was this technician — one ordered trail, from both attribute tables. A technician's positions live in two places because they answer two different questions: WKMECHADJAttribute holds where they were when they clocked, keyed to the attendance line, and WKMECHWKAttribute holds where they were while working a job, keyed to the job line. This is the union, so "where was Andries today" is one query instead of two.

Its own file, for the reason Capacity & Load is: two owners, one join. The clock side belongs to Technician Attendance and the job side to Technician Work, and putting this read inside either would make that file query a table it does not own — which is the one it would get wrong first. It borrows both projections rather than restating them.

GET workshop_technician_positions.php ?technician_code= &date= &source= …

Built 2026-09-10 to settle a question rather than to add a report. ServiceConnect asked to file clock positions on the job table with a null ro_number, so that "where was this technician" would be one query. The mechanism was refused; the requirement was built. On WKMECHWKAttribute the key columns are RO_BRANCH, RO_NUMBER, JOB_CODE, JOB_TYPE, MECHANIC_CODE, SEQ and a clock press has exactly one of them — such a row is not filed against no job, it is unfiled. Clock positions belong on WKMECHADJAttribute, through the attendance attribute write, which needs no RO because its key is complete without one. Saying "put them somewhere else" without giving back the single query would have traded a real requirement for a structural preference, which is what this endpoint exists to avoid.

The two sources are dated on different facts, and that is the trap. A clock position carries ADJ_DATE — the day the attendance is for, written at midnight, authoritative. A job position has no date column at all: its key is a job line, so it is dated by insDt, the moment it was recorded. date_basis says which on every row and summary.date_basis counts them. It matters only at the edges of a day, which is exactly when it will not be noticed — a tracker position written at 00:10 for work done before midnight is dated the new day here, because nothing on the row says otherwise. A clock position never moves, because ADJ_DATE is a statement rather than a timestamp.
The order is when rows were WRITTEN, not when they happened. Ordering is insDt on both sides. On a live clock press or a live tracker that is the moment it occurred; on a write a phone queued through a dead spot it is the moment it arrived, which can be hours later. Neither table carries anything that tells those apart. So a trail is the order positions reached the DMS — and a line that doubles back on a map is probably this, not a technician who walked backwards. Said here rather than discovered from the map.
Coordinates are parsed, not just passed through. Both writers validate attr_value as "lat,lng" or "lat,lng,accuracy_metres" for the LocationOn / LocationOff types, so the format is known rather than hoped for: every row carries latitude, longitude and accuracy_m as numbers, with the raw value beside them. Parsed defensively and bounds-checked — a value that parses as three numbers but puts the technician at latitude 300 is not a position, and passing it through as one puts a pin in the sea on somebody's map, the failure being that it looks like data rather than like an error. Anything that does not parse keeps its raw value and leaves the three null; no row is ever dropped for failing to be a coordinate, and summary.coordinates counts both outcomes. Each row also keeps the key of the table it came from — line_no on clock rows, ro_number/job_code/job_type on job rows, never both.
?schema=1 answers the open question from the spec. It reports both tables' live columns and the foreign keys on them. That is the point of it: the sibling table wkRoFileAttribute carries AttributeCascade on (BRANCH, RO_NUMBER) → WkRoFile, and on 2026-09-07 it refused a commit when a bound RO_NUMBER did not resolve to a real repair order. Whether WKMECHWKAttribute carries the same constraint decides whether an ro_number of 0 on a clock position is an untidy row or an impossible one — and nothing had ever tested it, because the table was confirmed empty on 2026-09-04. The mode reads back a plain-English reading of what it found.

Indexes. The clock side is served by the existing AITONE_WKMECHADJATTR_TECH (tech_code, adj_date, line_no, attrType), which leads with exactly the pair this filters on. The job side is not served by anything that existed — AITONE_WKMECHWKATTR_RO leads with the repair order, which this read does not know — so AITONE_WKMECHWKATTR_MECH (mechanic_code, insDt) is new in ensure_indexes.php and is the entry that backs it. insDt is the second column because the table has no date of its own; an index on the column a query is forced to range on is the only shape available.

KeyTypeRequiredDescription
technician_codestringoptComma list. tech_code and mechanic_code are aliases. Matched against TECH_CODE on the clock side and MECHANIC_CODE on the job side — the same person, two column names.
datedateoptYYYY-MM-DD. Default: today on the database clock. One day, where most reads here default to a fortnight or a year — a trail is read about a person on a date, and a default spanning two weeks would return a fortnight of coordinates to somebody who asked about one shift.
date_fromdateoptWiden it deliberately. Half-open on the bare column on both sides, so the last day is whole and each index is still seeked.
date_todateopt
branchstringoptComma list. TECH_BRANCH on clock rows — read from the WKMECHADJ line the attribute hangs off, since the attribute table has no branch column — and RO_BRANCH on job rows.
attr_typestringoptComma list, e.g. LocationOn,LocationOff. Narrow with this if you are drawing a map: those two are the only types either writer validates as coordinates.
sourcestringoptboth (default) | attendance (clock positions only) | job (tracker positions only).
schemaflagopt1 = both tables' columns with nullability, and the foreign keys on them. Run this first on a new install — see the panel above.
pageintegeropt1. Paging never changes summary or technicians[]: both are computed over the whole window.
limitintegeroptRows per page; blank = all.
debugflagopt1 = per-query timing / size in a _debug block.
json
{ "meta": {
    "source": "WKMECHADJAttribute (clock positions) + WKMECHWKAttribute (job positions)",
    "grain": "one recorded position", "measured": false,
    "window": { "from": "2026-09-10", "to": "2026-09-10" } },
  "summary": { "positions": 6, "technicians": 1,
    "by_source": { "attendance": 4, "job": 2 },
    "by_attr_type": { "LocationOn": 3, "LocationOff": 3 },
    // how much of the answer rests on the weaker of the two datings
    "date_basis": { "adj_date": 4, "recorded": 2 },
    "coordinates": { "parsed": 6, "unparsed": 0 },
    "warning": null },
  "technicians": [
    { "technician_code": "Andries Mashego", "positions": 6,
      "first_at": "2026-09-10 07:28:11.000000",
      "last_at": "2026-09-10 17:02:48.000000" }],
  "rows": [
    // the clock-on — keyed to the attendance LINE, no RO anywhere
    { "source": "attendance", "technician_code": "Andries Mashego",
      "date": "2026-09-10", "date_basis": "adj_date",
      "at": "2026-09-10 07:28:11.000000", "branch": "100",
      "attr_type": "LocationOn",
      "latitude": -25.8344618, "longitude": 28.1432052,
      "accuracy_m": 79,
      "attr_value": "-25.8344618,28.1432052,79",
      "line_no": 0, "ro_number": null, "job_code": null },
    // the tracker — keyed to the JOB line, and dated by when it was written
    { "source": "job", "date_basis": "recorded",
      "at": "2026-09-10 09:14:03.000000", "branch": "100",
      "attr_type": "LocationOn", "seq_no": 3,
      "latitude": -25.8096407, "longitude": 29.4637208,
      "line_no": null, "ro_number": 45217,
      "job_code": "SERVICE", "job_type": "R" }] }

Parts Inventory

Reads from InMaster (Inventory Master). A single part (exact part_no) includes its last 20 movements from InTrans. The list uses the same filter vocabulary as the ageing endpoints and returns branch / franchise buckets that seed and cross-filter the pickers; ?group_by=part_no switches to per-part rows for the Part pickers. For grouped rollups, see Parts Inventory Analysis and Parts Sales Analysis.

GET parts.php ?part_no= &group_by= &branch_include= …

Modes. Single part: an exact part_no (no *, no group_by) returns the part master + recent movement. Detail (group_by=part_no): per-part rows exposing part_no, part_description (+ im.* compliance fields) for the Part pickers. Stocktake ageing (group_by=stocktake_age): a compact summary — one row per branch × franchise × age bucket (months since last stocktake: -1 never counted, 0 future, 1..24, 25 = 25+), each with n, n_onhand, val, val_onhand; returns as_at + branch/franchise + stocktake_age_summary (no raw data). Margin band (group_by=margin_band): a margin histogram, one flat row per branch × franchise × occupied band. Band code is -2 unpriced (list_price≤0), -1 loss (margin<0), else floor(gross_margin/5)*5 → 0,5,…,95,100 (margin clamped to 100). Per row: n, val (Σon_hand_val), sell (Σlist_price×on_hand_qty), cost (Σon_hand_val) — sell/cost are 0 for the unpriced band. Population is on-hand-only; turns the multi-MB raw margin pull into a <100 KB payload. Aggregate (default list): also returns top-level branch and franchise arrays over the filtered scope to seed & cross-filter the Branch/Franchise pickers. A trailing * on part_no/part_desc is a prefix search (routes to the list).

The part master is returned as-is (SELECT im.*) so every real InMaster column comes through. The low_stock / in_stock filters return once the InMaster stock columns are confirmed.
KeyTypeRequiredDescription
part_nostringoptExact = single part + movement; trailing * = prefix search (list)
part_descstringoptExact, or trailing * for prefix search
group_bystringoptpart_no (per-part rows) · stocktake_age (stocktake-ageing summary) · margin_band (margin histogram)
branch / franchisestringoptExact match
branch_includestringoptCSV of branch codes to include
franchise_includestringoptCSV of franchise codes to include
part_no_includestringoptCSV of part numbers to include (each may end *)
part_desc_includestringoptCSV of description tokens (contains-match)
stocktake_beforedateoptNon-compliant drill: parts last counted before YYYY-MM-DD or never counted
in_stockintegeropt1 = only parts with on_hand_qty ≠ 0. Default = all. Applies to every mode
margin_maxnumberoptLeaf drill: on-hand priced parts with gross margin % below this (thin + loss)
include_unpricedintegeroptWith margin_max, also return on-hand parts with list_price ≤ 0
binstringoptPartial bin-location match
searchstringoptpart number or description
page / limitintegeroptlimit blank = all rows; number = that many (else 20)
json — aggregate (list)
{ "meta": { "total": 8421 },
  "branch": ["01", "04"],
  "franchise": [{ "franchise": "TY", "franchise_name": "Toyota" }],
  "data": [{ "PART_NO": "90915-YZZE1", "PART_DESC": "Oil Filter",
    "BRANCH": "01", "FRANCHISE": "TY", "bin_location": "A12-4" }] }
Detail — ?group_by=part_no
json — detail
{ "meta": { "total": 3 }, "group_by": "part_no",
  "data": [{ "part_no": "90915-YZZE1", "part_description": "Oil Filter",
    "branch": "01", "franchise": "TY", "franchise_name": "Toyota" }] }
Stocktake ageing — ?group_by=stocktake_age
json — stocktake_age
{ "as_at": "2026-07-01",
  "branch": ["01", "61"],
  "franchise": [{ "franchise": "RT", "franchise_name": "Revet" }],
  "stocktake_age_summary": [{ "branch": "61", "franchise": "RT", "franchise_name": "Revet",
    "age_bucket": -1, "n": 12, "n_onhand": 3, "val": 45000.00, "val_onhand": 12000.00 },
    { "branch": "61", "franchise": "RT", "franchise_name": "Revet",
    "age_bucket": 9, "n": 40, "n_onhand": 8, "val": 88000.00, "val_onhand": 21000.00 }] }
Margin band — ?group_by=margin_band
json — margin_band
{ "as_at": "2026-07-06",
  "branch": ["61"], "franchise": [{ "franchise": "JD", "franchise_name": "John Deere" }],
  "margin_band_summary": [
    { "branch": "61", "franchise": "JD", "franchise_name": "John Deere",
      "margin_band": -2, "n": 88, "val": 9100.00, "sell": 0.00, "cost": 0.00 },
    { "branch": "61", "franchise": "JD", "franchise_name": "John Deere",
      "margin_band": -1, "n": 42, "val": 18400.00, "sell": 15900.00, "cost": 18400.00 },
    { "branch": "61", "franchise": "JD", "franchise_name": "John Deere",
      "margin_band": 15, "n": 42, "val": 183402.55, "sell": 240100.00, "cost": 183402.55 }
    /* … one row per (branch × franchise × occupied band); bands 0,5,…,95,100 */
  ] }

Parts Inventory Analysis

Groups InMaster (Inventory Master) by any column you pass — franchise (default), branch, prod_group, etc. Always returns part counts, distinct parts and back-order quantity per group. Pass ?sum= to total any numeric InMaster columns you have (e.g. on-hand qty or stock value).

GET parts_inventory_analysis.php ?group_by= &sum= …

The grouping column is validated as a plain identifier. An unknown column returns a clear error rather than affecting other data.

KeyTypeRequiredDescription
group_bystringoptGrouping column (default FRANCHISE) — e.g. branch, prod_group
sumstringoptComma-separated numeric columns to SUM per group
branch / franchisestringoptRestrict to one branch / franchise
limitintegeroptGroups returned — blank = all, number = that many (else 20)
json
{ "group_by": "FRANCHISE",
  "groups": [{ "FRANCHISE": "TOY", "parts": 8420,
    "distinct_parts": 8011, "back_order_qty": 96.00, "branches": 3 }],
  "totals": { "groups": 6, "parts": 48210, "back_order_qty": 412.00 } }

Parts Stock Ageing Summary

On-hand value from InMaster (parts with ON_HAND_QTY <> 0), cross-tabulated by two age dimensions: DLP age (months since last purchased) and DLS age (months since last invoiced / sold). Bands: 0-1, 2-12, 13-24, 25-36, 37-48, 48+; DLS also has No Sale for parts never sold. Each cell carries on_hand_val and on_hand_ex_wip (value excluding WIP).

GET parts_stock_ageing.php ?wip= &branch_exclude= …

Summary cross-tab of on-hand value by DLP × DLS age band, plus a grand total. Each cell carries on_hand_val and on_hand_ex_wip (on-hand value less WIP). Cells are returned in chronological band order. The response also lists the distinct branch codes and franchise (code + name) in scope — handy for populating filter dropdowns; both reflect the active filters.

KeyTypeRequiredDescription
wipintegeropt1 = only parts with WIP_QTY <> 0
franchisestringoptRestrict to one franchise code
franchise_includestringoptComma list of franchise codes to include
part_nostringoptRestrict to one part — exact, or a trailing * for prefix search (e.g. 90915*)
part_no_includestringoptComma list of part numbers to include — each may end in * for prefix
part_desc_includestringoptComma list of description terms — matches descriptions containing any term
branch_includestringoptComma list of branch codes to include
branch_excludestringoptComma list of branch codes to exclude
json
{ "total_on_hand_val": 4128500.00, "total_on_hand_ex_wip": 4061250.00,
  "bands": ["0-1", "2-12", "13-24", "25-36", "37-48", "48+", "No Sale"],
  "branch": ["01", "02", "07"],
  "franchise": [{ "franchise": "TY", "franchise_name": "Toyota" },
    { "franchise": "NS", "franchise_name": "Nissan" }],
  "data": [{ "dlp_age": "0-1", "dls_age": "0-1", "on_hand_val": 512300.00, "on_hand_ex_wip": 509100.00 },
    { "dlp_age": "48+", "dls_age": "No Sale", "on_hand_val": 88010.00, "on_hand_ex_wip": 88010.00 }] }

Parts Stock Ageing Detail

The drill-down behind Parts Stock Ageing Summary: the individual on-hand parts, with per-line cost detail (unit_cost = on_hand_val ÷ on_hand_qty, wip_val = unit_cost × wip_qty, on_hand_ex_wip = on_hand_val − wip_val) plus the franchise name. dlp_age/dls_age are optional filters — pass both to pin one cell of the cross-tab, one to slice a single age dimension, or neither to list all parts. Add ?group_by=branch|franchise|part_no to switch to a roll-up: one aggregated row per group instead of per part, each carrying three age breakdowns of its own parts — by_dlp (across DLP bands), by_dls (across DLS bands) and by_cell (the full DLP×DLS crossed grid).

GET parts_stock_ageing_detail.php ?risk_parts=1 &group_by= &dlp_age= &part_no= …

Four modes. Risk-parts mode (?risk_parts=1, takes precedence): the Pulse Parts Risk feed's part-grain facts only — the dead-value Tukey fence, the flagged set with its values, the counts and totals (see the param table). A few KB, replacing a 96 MB / 48 s group_by=part_no pull. Risk-lines mode (?risk_lines=): the SKU lines behind one Risk metric — three fields per part instead of the whole band grid (that drill was 73.2 MB / 36.3 s for one franchise). Part mode (default): the individual on-hand parts, with cell_totals. dlp_age/dls_age are optional here — pass both to pin one cell, one to slice an age dimension, or neither to list all parts (narrow with the franchise/branch/part filters). Group mode (?group_by=): one aggregated row per branch, franchise or part_no, each with by_dlp, by_dls and by_cell (the full DLP×DLS grid) breakdowns of its own parts, plus grand totals — bands optional. group_by=part_no rows also include part_description, and group_by=franchise rows include franchise_name. The three breakdowns are slices of the same parts (each sums to the group total; the grid's cells sum along each axis to by_dlp / by_dls). An invalid band or group_by returns 400 with the valid list.

KeyTypeRequiredDescription
risk_partsintegeropt1 = Parts Risk feed mode (takes precedence over group_by). Returns only the part-grain facts that feed lights the Risk tab: meta.dead_threshold (the upper Tukey fence over each part's dead value — the No Sale DLS band's on-hand value, floor R50 000, computed in PHP as a byte-for-byte port of the client's tukeyFence), meta.part_count (every part group — the "across N parts" population), meta.dead_part_count (every part holding ANY dead stock), meta.dead_flagged_count and meta.dead_flagged_value (the count and total of the set the fence actually selects), flagged_dead[] (that whole set, worst-first: part number, description, value, plus branch_count and — when exactly one branch holds it — branch, so the caller can name where the stock sits without a drill; 161 of 199 are single-branch on the reference dealer) plus flagged_parts[] and top_dead[] (part numbers only, and the worst 25 — both superseded by flagged_dead and kept so an older client keeps working). Replaces an unpaginated ?group_by=part_no call that returned 96 MB in 48.3 s — the largest call in the platform — of which the client used exactly those four facts.
risk_linesstringoptdead · obsolete · fresh · wip_aged — the SKU lines behind one Risk metric (also takes precedence over group_by): part_no, part_description and that metric's val, non-zero only, worst-first. What the Risk tab's "Dead-stock lines" / "Obsolete lines" drill renders. Metric definitions are shared with risk_parts, so a drill agrees with the fence above it. Previously served by ?group_by=part_no scoped to the drilled franchise — John Deere alone was 73.2 MB / 36.3 s to read one number per part. Unknown value → 400.
group_bystringoptbranch · franchise · part_no — switches to the roll-up (group) mode. Never call part_no unpaginated or unfiltered: every row carries the full DLP×DLS grid plus both margins, so the payload scales at roughly 2.4 KB per part. Use ?risk_parts=1 for the Risk feed, or narrow with branch_include/franchise_include/page.
dlp_agestringopt0-1 · 2-12 · 13-24 · 25-36 · 37-48 · 48+  filter — both pin a cell, one slices
dls_agestringopt…same, plus No Sale  filter — both pin a cell, one slices
franchisestringoptRestrict to one franchise code
franchise_includestringoptComma list of franchise codes to include
part_nostringoptRestrict to one part — exact, or a trailing * for prefix search (e.g. 90915*)
part_descstringoptRestrict by description — exact, or a trailing * for prefix search (e.g. BRAKE*)
part_no_includestringoptComma list of part numbers to include — each may end in * for prefix
part_desc_includestringoptComma list of description terms — matches descriptions containing any term
branch_includestringoptComma list of branch codes to include
branch_excludestringoptComma list of branch codes to exclude
page / limitintegeroptlimit blank = all rows; number = that many (else 20)
json — part mode
{ "meta": { "total": 214 }, "mode": "part",
  "cell": { "dlp_age": "2-12", "dls_age": "No Sale" },
  "cell_totals": { "parts": 214, "on_hand_val": 186400.00, "on_hand_ex_wip": 183010.00 },
  "data": [{ "part_no": "90915-YZZE1", "franchise": "TY", "franchise_name": "Toyota",
    "unit_cost": 43.20, "on_hand_qty": 12, "on_hand_val": 518.40, "wip_val": 0.00 }] }
Group mode — ?group_by=branch
json — group mode
{ "meta": { "total": 6 }, "mode": "group", "group_by": "branch",
  "cell": { "dlp_age": null, "dls_age": null },
  "totals": { "groups": 6, "parts": 5840, "on_hand_val": 2841900.00, "on_hand_ex_wip": 2790110.00 },
  "data": [{ "branch": "01", "parts": 1820, "on_hand_qty": 9410.00,
    "on_hand_val": 980450.00, "wip_val": 12300.00, "on_hand_ex_wip": 968150.00,
    "by_dlp": [{ "age": "0-1", "parts": 240, "on_hand_val": 120300.00 },
              { "age": "2-12", "parts": 910, "on_hand_val": 540150.00 },
              { "age": "48+", "parts": 670, "on_hand_val": 320000.00 }],
    "by_dls": [{ "age": "2-12", "parts": 1100, "on_hand_val": 610200.00 },
              { "age": "No Sale", "parts": 720, "on_hand_val": 370250.00 }],
    "by_cell": [{ "dlp_age": "0-1", "dls_age": "2-12", "parts": 180, "on_hand_val": 90200.00 },
               { "dlp_age": "2-12", "dls_age": "No Sale", "parts": 410, "on_hand_val": 220050.00 },
               { "dlp_age": "48+", "dls_age": "No Sale", "parts": 310, "on_hand_val": 150000.00 }] }] }

Parts Sales

Lists InTrans (Inventory Sales) — the parts sales / movement ledger. Each row is a sale line with quantity, sale value, tax and the list price it sold against. For grouped totals, see Parts Sales Analysis.

GET parts_sales.php ?part_no= &branch= &date_from= …

Paginated sales lines with page totals (qty, sale value, tax).

KeyTypeRequiredDescription
part_nostringoptExact part number
branch / franchisestringoptExact match
salesmanstringoptSalesman code
sale_class / trade_typestringoptExact match
acc_nointegeroptBill-to account
ref_nostringoptDocument / order reference
date_from / date_todateoptTrans_Datetime range
searchstringoptpart no, description, ref, salesman
page / limitintegeroptlimit blank = all rows; number = that many (else 20)
json
{ "meta": { "total": 28940 },
  "page_totals": { "qty": 312.00, "sale_value": 48250.00 },
  "data": [{ "PART_NO": "90915-YZZE1", "qty": 2,
    "sale_value": 258.00, "branch": "01", "franchise": "TOY" }] }

Parts Sales Analysis

Groups InTrans (Inventory Sales) by any column you pass — franchise (default), branch, PART_NO (top sellers), Salesman, SALE_CLASS, etc. Each group carries lines, qty, sale value, tax, list value and gross discount; ordered by sale value.

GET parts_sales_analysis.php ?group_by= &date_from= &limit= …

Discount is list value (LIST_PRICE × QTY) less sale value — a gross discount, not margin. group_by=PART_NO&limit=50 gives the top 50 selling parts.

KeyTypeRequiredDescription
group_bystringoptGrouping column (default FRANCHISE) — e.g. branch, PART_NO, Salesman
date_from / date_todateoptTrans_Datetime range
branch / franchisestringoptExact match
trade_type / sale_classstringoptExact match
salesmanstringoptSalesman code
limitintegeroptGroups returned — blank = all, number = that many (else 20)
json
{ "group_by": "FRANCHISE",
  "groups": [{ "FRANCHISE": "TOY", "lines": 4821, "qty": 15230.00,
    "sale_value": 1875400.00, "discount_value": 210600.00, "discount_pct": 10.10 }],
  "totals": { "groups": 6, "sale_value": 4210880.00 } }

Parts Analytics

A combined inventory + sales overview across InMaster (stock catalogue) and InTrans (sales movement). Every response carries an inventory summary (parts, distinct parts, back-order qty, branch/franchise counts), a sales summary (lines, qty, sale value, tax, list value, gross discount) and a groups breakdown for the chosen dimension. Discount is list value (LIST_PRICE × QTY) less sale value — a gross discount, not margin; stock valuation and true margin are omitted until the InMaster cost columns are confirmed.

GET parts_analytics.php ?group_by= &date_from= &branch= …

group_by chooses the sales breakdown dimension: franchise (default), branch, salesman, sale_class or trade_type give one group per code; part gives top parts by sale value (bounded by limit); month gives a monthly trend. When grouping by franchise or branch, each group also carries its catalogue counts.

KeyTypeRequiredDescription
group_bystringoptSales dimension (default franchise) — branch, salesman, part, month, sale_class, trade_type
date_from / date_todateoptTrans_Datetime range (sales side)
branch / franchisestringoptRestrict both tables to one branch / franchise
trade_type / sale_classstringoptExact match (sales side)
limitintegeroptRows for group_by=part — blank = all, number = that many (else 20)
json
{ "group_by": "franchise",
  "inventory": { "parts": 16816, "distinct_parts": 15920, "back_order_qty": 418.00, "branches": 7, "franchises": 12 },
  "sales": { "lines": 28450, "qty": 92300.00, "sale_value": 4210880.00,
    "sale_tax": 631632.00, "list_value": 4684300.00, "discount_value": 473420.00, "discount_pct": 10.11 },
  "groups": [{ "franchise": "TOY", "sales": { "lines": 4821, "sale_value": 1875400.00 },
    "parts": 5210, "distinct_parts": 4980, "back_order_qty": 120.00 }] }

Parts Sales Orders

Customer parts sales orders — the orders customers place to buy parts from the dealership. Reads the order header (insalord, one row per order) joined to the order line detail (insalpar, one row per part) on file_no. The list returns headers with per-order line counts, quantity roll-ups and a derived status; ?file_no= returns one order with its header and every line.

GET parts_sales_orders.php ?file_no= &customer_no= &open= &part_no= …

Paginated order list, newest first. Each header carries line_count, total_order_qty, total_supplied_qty, total_outstanding_qty, an order_total (line value + freight) and a status of open / part_supplied / supplied. Pass ?file_no= for one order with header + lines; add &lines_only=1 for just the lines.

KeyTypeRequiredDescription
file_nointegeroptSingle order — returns header + all lines + totals
lines_onlyintegeropt1 = with file_no, return only the line detail
branchstringoptOriginating branch code
customer_nostringoptCustomer account / number
salesmanstringoptSalesman code (partial match)
cust_ord_nostringoptCustomer's own order reference (partial)
typestringoptOrder TYPE code
pay_methodstringoptPayment method
part_nostringoptOnly orders containing this part
openintegeropt1 = orders with parts still to supply
fully_suppliedintegeropt1 = orders with nothing left to supply
date_from / date_todateoptord_date range — YYYY-MM-DD
searchstringoptcust_ord_no, customer_no, salesman, special_inst
page / limitintegeroptlimit blank = all rows; number = that many (else 20)
json
{ "meta": { "total": 1248 },
  "data": [{ "file_no": 120345, "branch": "JHB01", "cust_ord_no": "PO-7788",
    "customer_no": "1001", "salesman": "DV", "ord_date": "2026-06-20 10:14:00",
    "line_count": 3, "total_order_qty": 9.00, "total_supplied_qty": 4.00,
    "total_outstanding_qty": 5.00, "order_total": 1289.45, "status": "part_supplied" }] }
Single order — ?file_no=120345
json
{ "header": { "file_no": 120345, "branch": "JHB01", "customer_no": "1001",
    "cust_ord_no": "PO-7788", "freight": 120.00, "pay_method": "ACCOUNT" },
  "totals": { "order_qty": 9.00, "supplied_qty": 4.00, "line_value": 1169.45,
    "freight": 120.00, "order_total": 1289.45 },
  "lines": [{ "line_no": 1, "part_no": "90915-YZZE1", "order_qty": 4.00,
    "supplied_qty": 4.00, "unit_price": 129.95, "line_value": 519.80,
    "outstanding_qty": 0.00, "branch": "JHB01", "franchise": "TY" }] }
POST parts_sales_orders.php ?dry_run=1

Creates a new order — the insalord header first, then an insalpar row per line. The body is { "header": {…}, "lines": [ {…} ] }; part_no is the only field a line must carry, and a line without one is a 400 naming its index. Returns 201 with file_no and the lines_created numbers.

This maintains the order document and nothing else. It does not post to the financial ledger and does not commit stock — those flow through Auto-IT so balances, allocations and inventory stay consistent. An order written here is a document Auto-IT will pick up, not a transaction that has happened. Same scope rule as every other write in this API.
file_no is MAX(file_no) + 1, read and assigned without a lock. Two creates landing in the same instant can therefore choose the same number, and the second insert fails on the key rather than silently merging — but it is a race, and a batch importer running in parallel will find it. Send an explicit file_no in the body when you are allocating numbers yourself; that path skips the lookup entirely. line_no is sequential from 1 within the request unless a line carries its own.

Only whitelisted columns are ever written, and anything else in the body is silently dropped rather than rejected — so a typo in a field name is not an error, it is a field that did not get written. Check the response, or run ?dry_run=1 and read the bound parameters, before assuming a value landed. Values are always bound as prepared-statement parameters (odbcExecParams), never interpolated. The header whitelist runs to 44 columns and the line whitelist to 50; a 400 on an empty header returns the full header list in editable_header_fields, which is the quickest way to see it.

Header and lines are separate statements with no transaction around them. Technician Attendance is the only write in this codebase that takes one. If a line insert fails after the header committed, the order exists with fewer lines than you sent and the response is the 500 — reconcile against lines_created rather than assuming all-or-nothing. ?dry_run=1 returns every statement in execution order with its parameters, which is the cheap way to find that out first.
KeyTypeRequiredDescription
WRITEKEYheaderreqWrite auth, on top of the two read headers. Compared with hash_equals.
headerobjectreqBody field. At least one editable header column, else 400 with the full list in editable_header_fields.
linesarrayoptBody field. One object per part; part_no required on each. Omit for a header-only order.
file_nointegeroptBody field. Forces the order number and skips the MAX+1 lookup — the way to avoid the race when you allocate numbers yourself.
dry_runflagoptQuery param. 1 ⇒ returns every statement with its bound parameters in execution order and commits nothing.
json
{ "status": "ok", "action": "created",
  "file_no": 44127, "lines_created": [1, 2, 3] }

// ?dry_run=1 — nothing is written
{ "dry_run": true, "file_no": 44127,
  "statements": [
    { "sql": "INSERT INTO insalord (file_no, branch, customer_no, …, rec_control) VALUES (?, ?, ?, …, CURRENT TIMESTAMP)",
      "params": [44127, "JHB01", "1001"] },
    { "sql": "INSERT INTO insalpar (file_no, line_no, part_no, order_qty, …) VALUES (?, ?, ?, ?, …)",
      "params": [44127, 1, "ABC123", 4] }],
  "note": "No data was written. Remove ?dry_run=1 to commit." }
PATCH parts_sales_orders.php ?dry_run=1

Amends an existing order. The body must carry file_no, and the order must exist — a missing one is a 404 rather than a create. Any combination of three things may follow, and a body carrying none of them is a 400 rather than a silent no-op: header updates header columns, lines updates or inserts line rows, and delete_lines removes them by number.

line_no decides whether a line is edited or added, and there is no third option. A line object with line_no updates that row; a line object without one is inserted as a new line. So an object you meant as an edit, sent with the key misspelled or dropped, silently adds a duplicate part to the order instead of changing the one that is there. Run ?dry_run=1 and count the INSERTs against the UPDATEs before committing a batch.

applied is the receipt. The response returns one entry per statement — keyed by what it did — carrying the rows each affected. A 0 against an update is not an error and not a failure: it means the row matched but nothing changed, or the line_no does not exist on that order. Read it rather than assuming the 200 covered everything you sent. The same whitelist and the same silent-drop rule as POST apply to both objects.

KeyTypeRequiredDescription
WRITEKEYheaderreqWrite auth, on top of the two read headers.
file_nointegerreqBody field. The order to amend. Absent ⇒ 400; not found ⇒ 404.
headerobjectoptBody field. Editable header columns to set.
linesarrayoptBody field. With line_no ⇒ update that line. Without ⇒ insert a new one.
delete_linesarrayoptBody field. Line numbers to remove from this order, e.g. [3, 4].
dry_runflagoptQuery param. 1 ⇒ preview every statement with its parameters; commits nothing.
json
{ "status": "ok", "action": "updated",
  "file_no": 44127,
  // rows affected per statement — a 0 means matched-but-unchanged,
  // or a line_no that is not on this order. Read it.
  "applied": { "header": 1, "line:2": 1,
                "line:new": 1, "delete:3": 1 } }
DELETE parts_sales_orders.php ?file_no= &dry_run=1

Deletes a whole order — every insalpar line first, then the insalord header. file_no comes from the query string here, not the body, which is the one place this endpoint's write half differs from the other two. An order that does not exist is a 404; nothing is deleted partially on that path because the check runs first.

There is no undo and no transaction. The lines and the header are two statements; if the second fails the order is left with a header and no lines, which still reads as an order. Nothing in this API can recover a deleted row. ?dry_run=1 first is not a formality on this verb — it is the only preview you get, and the response tells you how many lines are about to go.
KeyTypeRequiredDescription
WRITEKEYheaderreqWrite auth, on top of the two read headers.
file_nointegerreqQuery param, not a body field — the one place this differs from POST and PATCH. Missing ⇒ 400; not found ⇒ 404.
dry_runflagoptQuery param. 1 ⇒ shows both DELETE statements and their parameters; removes nothing.
json
{ "status": "ok", "action": "deleted",
  "file_no": 44127,
  "lines_deleted": 3, "header_deleted": 1 }

Parts Audit

Parts-related entries from the application audit log (uaudit). Each row is one logged action — who (username, win_username, win_pcname), when (ad_timestamp), what (ad_type), the record touched (identity1), and before/after values (old_value / new_value). ad_type is whitelisted to parts events only — currently insalpar_delete (a parts sales-order line deletion); the response lists the recognised types under ad_types.

GET parts_audit.php ?ad_type= &username= &date_from= …

Paginated parts audit log, newest first. Only rows whose ad_type is whitelisted are returned. Pass ?adid= for a single record. ?ad_type= accepts only a whitelisted value (else 400 with the allowed list).

KeyTypeRequiredDescription
adidintegeroptSingle audit record by id
ad_typestringoptOne whitelisted type — currently insalpar_delete
usernamestringoptAuto-IT user who performed the action (partial)
session_idintegeroptExact session id
connection_idstringoptExact connection id
identity1stringoptRecord identity the entry refers to (partial)
searchstringoptusername, int_window, identity1, old/new value, other
date_from / date_todateoptad_timestamp range — YYYY-MM-DD
page / limitintegeroptlimit blank = all rows; number = that many (else 20)
json
{ "meta": { "total": 37 },
  "ad_types": ["insalpar_delete"],
  "data": [{ "adid": 90412, "ad_timestamp": "2026-06-22 14:08:31", "ad_type": "insalpar_delete",
    "username": "DVANROOYEN", "session_id": 2241, "win_pcname": "PARTS-PC-03",
    "identity1": "120345/2", "old_value": "part 90915-YZZE1 qty 4", "new_value": null }] }

Parts Stock Adjustment

Parts stock adjustments from InTrans and the InTrans_Old archive — rows where TYPE = 'A' that moved quantity or value, classified In / Out by the sign of quantity, or by the sign of cost when quantity is zero. A pure revaluation (price correction against stock already on hand) carries a cost value and no quantity movement, so it has no quantity sign to read — those used to be dropped entirely, and are now included and classified on their money. Only a line that moved neither quantity nor value is excluded. Stocktake entries are included by default; pass exclude_stocktakes=1 to drop them. Joined to InMaster for the description and contact for the adjusting salesman. Returns a summary (in/out counts, qty and cost) plus the detail lines.

GET stock_adjustments.php ?date_from= &branch= &direction= …

The live and archive tables are unioned by default; pass live_only=1 for InTrans alone. Cost is cost_val; resulting_qty / resulting_value are the on-hand position after each adjustment.

KeyTypeRequiredDescription
date_from / date_todateopttrans_datetime range
branch / franchisestringoptExact match
part_nostringoptPart number — exact, or a trailing * for prefix search (e.g. 90915*)
directionstringoptin or out. Sign of qty; when qty is zero, sign of cost (a revaluation has no quantity sign to read)
salesmanstringoptSalesman / contact code
searchstringoptpart number or description
exclude_stocktakesintegeropt1 = drop rows whose description contains STOCKTAKE
live_onlyintegeropt1 = skip the InTrans_Old archive
page / limitintegeroptlimit blank = all rows; number = that many (else 20)
json
{ "meta": { "total": 1840 },
  "summary": { "in_count": 1120, "out_count": 720,
    "net_qty": 240.00, "net_cost": 18420.50 },
  "data": [{ "part_no": "90915-YZZE1", "part_description": "Oil Filter",
    "description": "STOCKTAKE VARIANCE", "qty": -2,
    "cost_value": 86.40, "adjustment_type": "Stock Adjustments Out",
    "salesman_name": "Jan Smit", "resulting_qty": 40 }] }

part_description is the part's master description (InMaster); description is the transaction's own description from the adjustment row.

Parts Stock Adjustment Analysis

Aggregates the same adjustment set as Parts Stock Adjustment (InTrans + InTrans_Old, TYPE='A', rows that moved quantity or value) into groups by branch, franchise or part_no. Each group carries in/out line counts, in/out and net qty, and in/out and net cost — and because a zero-quantity revaluation is classified on the sign of its cost rather than falling outside both buckets, in_cost + out_cost still reconciles to net_cost on every group row. group_by=franchise adds franchise_name; group_by=part_no adds part_description. Pass ?include_buckets=1 to also get the distinct branch codes and franchise (code + name) that have adjustments in scope — for filter dropdowns. Buckets are off by default (each scans the full union), so omit the parameter for best speed.

GET stock_adjustments_analysis.php ?group_by= &date_from= &branch= …

Grouped adjustment totals. group_by is restricted to branch, franchise or part_no (default franchise). All the Parts Stock Adjustment filters apply.

KeyTypeRequiredDescription
group_bystringoptbranch · franchise · part_no (default franchise)
date_from / date_tostringopttrans_datetime range, YYYY-MM-DD
branch / franchise / part_nostringoptRestrict the set (part_no exact, or trailing * for prefix)
franchise_includestringoptComma list of franchise codes to include
part_no_includestringoptComma list of part numbers to include — each may end in * for prefix
part_desc_includestringoptComma list of description terms — matches descriptions containing any term
branch_includestringoptComma list of branch codes to include
branch_excludestringoptComma list of branch codes to exclude
directionstringoptin · out. Sign of qty; when qty is zero, sign of cost
exclude_stocktakesintegeropt1 = drop STOCKTAKE rows
live_onlyintegeropt1 = skip InTrans_Old archive
include_bucketsintegeropt1 = also return distinct branch/franchise in scope (off by default — slower)
limitintegeroptGroups returned — blank = all, number = that many (else 20)
json — with ?include_buckets=1
{ "group_by": "franchise",
  // branch[] and franchise[] only present when ?include_buckets=1
  "branch": ["01", "04"],
  "franchise": [{ "franchise": "TY", "franchise_name": "Toyota" },
    { "franchise": "BO", "franchise_name": "Bosch" }],
  "totals": { "groups": 6, "lines": 1840, "net_qty": -312.00, "net_cost": -48230.10 },
  "groups": [{ "franchise": "TY", "franchise_name": "Toyota", "lines": 920,
    "in_count": 540, "out_count": 380, "net_qty": -120.00, "net_cost": -19440.00 }] }

Analytics Snapshot

One call returning a management snapshot: period sales by module, AR aging totals, headline counts, vehicle stock by status, top customers and recent invoices.

GET dashboard.php ?date_from= &date_to=

Defaults to the current month if no dates are supplied. All figures derive from tables the platform already reads.

KeyTypeRequiredDescription
date_fromdateoptPeriod start · default: 1st of month
date_todateoptPeriod end · default: today
json
{ "period": { "date_from": "2026-06-01", "date_to": "2026-06-19" },
  "sales_by_module": [{ "module_type": "V", "sales_incl_tax": 2840500.00 }],
  "receivables_aging": { "total_outstanding": 1284300.00 },
  "counts": { "total_customers": 842, "invoices_in_period": 317 } }

Ensure Indexes

Admin maintenance endpoint. Holds a curated registry of the indexes the read endpoints benefit from, checks each against the live SQL Anywhere 17 catalog by column set (a vendor index under another name is respected, never duplicated), and creates only the ones not already covered. DDL is isolated here so read endpoints stay SELECT-only.

Dry-run is the default — nothing is created unless ?apply=1 is passed. Requires a WRITEKEY header in addition to the read keys. CREATE INDEX takes schema locks, so run it off the request path, once per dealer.
POST ensure_indexes.php ?apply=1 (default: dry-run)

Reports which curated indexes are missing (dry-run), or creates them with ?apply=1. Each registry entry is validated, checked against the catalog, and handled independently — one failure is reported without aborting the rest. Index names are namespaced AITONE_ so what the endpoint added is trackable.

KeyTypeRequiredDescription
applyintegeropt1 = create missing indexes. Omitted = dry-run (report only)
WRITEKEYheaderreqWrite authorization header (plus the usual AUTHKEY / APIKEY)
Always dry-run first and review the indexes[] report — entries with unconfirmed column names surface as error rather than being created.
json — dry-run
{ "status": "ok", "applied": false,
  "summary": { "registry_entries": 10, "created": 0, "already_present": 7, "errors": 0 },
  "indexes": [{ "index": "AITONE_INMASTER_BR_FR_ONHAND", "table": "InMaster",
    "status": "would_create", "ddl": "CREATE INDEX AITONE_INMASTER_BR_FR_ONHAND ON InMaster (branch, franchise, on_hand_qty, on_hand_val, list_price)" },
    { "index": "AITONE_INMASTER_BR_FR_PART", "status": "exists", "covered_by": "inmaster_pkey" }] }

Write Operations

Mutating endpoints. Each requires the standard read keys plus a WRITEKEY header, accepts a JSON body, binds every value as a prepared-statement parameter, and supports ?dry_run=1 to preview without committing.

Writes are limited to CRM/contact data, the parts sales-order document, the workshop technician endpoints — the attendance calendar and an attribute against one attendance line (an upsert: where the technician app files the position it records at clock on and off) beside an appending write against the same table for positions that repeat, the technician master file, and a single clocking line, which also hands a repair order to a technician (moved there from the booking feed on 2026-09-06, which now answers 410 naming it) and records a fact against a job line such as where a technician was when tracking started — and two things about a repair order — its progress status, the board column a job sits in,, the day it is booked for, its service advisor and whether the customer has confirmed it, plus an attribute against the job card such as a technician's note. Where several writes share a file they are chosen by an explicit mode flag, never by what the body contains, and on the clocking file and the attendance file an unrecognised flag is refused rather than falling through to whichever write is the default. All of them live on their own read endpoints rather than in this section, because each file owns its table in both directions — and Repair Orders owns both the stage list those moves are made from and the creation of a job card — the only write here that raises a record rather than correcting one. The attribute writes are the ones that append rather than correcting a row that already exists. The financial ledger and inventory quantities are not writable here by design — and neither is anything the DMS has already billed: an invoiced clocking line refuses every figure that reached the invoice.
POST customer_create.php ?probe=1 &dry_run=1 &code_strategy= …

The write half of the customer flow: the person who is not in the list because they have never been here before. Creates a row on CONTACT — the person master — and nothing else.

Read this before the first live call: who allocates contact_code. It is CHAR(15) NOT NULL and the key of the person master, with no identity, no default and no sequence in the DDL — which means something else allocates it, and this codebase has never seen what. Auto-IT almost certainly holds a counter somewhere. If it does and this endpoint invents codes beside it, the two will eventually choose the same one, and the loser is a customer master row that silently overwrites or refuses. So the default strategy is supplied: the caller passes contact_code and nothing is guessed. ?probe=1 answers the question empirically, and writes nothing.

?probe=1 first, then decide. It returns how many contacts exist, the length census of their codes, the ten lexically highest, and — the informative panel — the ten most recently created with their codes, creation dates and creationSource. If those run sequentially, something in the DMS is counting and you must find it before generating anything. If the census shows one dominant length, codes are fixed-width and ?code_strategy=next becomes reasonable: ?code_strategy=next&code_prefix=C&code_width=8 takes MAX(contact_code) over that prefix and width and adds one. String max, not numeric — on a zero-padded fixed-width run the two orderings agree, and the string form needs no CAST that a single alphabetic code anywhere in the table would blow up for every caller. It refuses rather than improvises when nothing matches, when the highest is not digits after the prefix, or when the run is exhausted.

?code_strategy=next races. Two callers a millisecond apart read the same MAX and build the same code; the second INSERT fails on the primary key. That is the same exposure parts_sales_orders.php's nextId() carries, mitigated the same modest way — the code is re-checked immediately before the insert — but a check-then-write is not atomic. It is for a back-office screen with one operator, not a public web form. Use supplied if two people can create customers at once.

Duplicates are the other way this goes wrong — a person master fills up with the same human three times because the advisor could not find them and made a new one. Before inserting, three probes run in one round trip: the supplied mobile reduced to digits against the same reduction of all three phone columns, email against both email columns, and surname AND name both exactly equal. A hit is a 409 carrying the candidates — code, display name, mobile, email, city, created date — so the client can offer "did you mean" instead of a failure. Nothing is written. ?allow_duplicate=1 proceeds anyway (two real people do share a name); ?check_duplicates=0 skips the probes.

No ArMaster row is created, and that is correct. This makes a person, not a debtor account — a retail service customer has none, which is why Customer Lookup exists at all, and inventing one would put a customer with no credit terms into the AR ageing. Opening an account is an Auto-IT function and stays one. At least one of surname or company_name is required: a contact with neither has a null display name and is invisible in the picker forever. Deliberately not writable though the columns exist: the bank block, Social_Security_No, tax_file_no, and the Ckc/Conv/survey bookkeeping.

Consent is captured at creation or not at all, so all five privacy flags are writable — and validated to Y/N/empty rather than coerced, because silently turning 1 into Y is how a flag ends up meaning something the person never agreed to. creationSource is stamped AUTO-IT-ONE-API unless you name your own screen, so API-created rows stay identifiable; Creation_Date is left to the column default and Last_Modified_Date is stamped in SQL.

KeyTypeRequiredDescription
probeflagopt1 = report how contact codes look on this install and write nothing. Run this before anything else.
dry_runflagopt1 = return the exact INSERT and its bound params without committing.
code_strategystringoptsupplied (default — body must carry contact_code) | next (generate, see the warnings above)
code_prefixstringoptWith code_strategy=next. Default empty, i.e. a pure numeric run.
code_widthintegeroptRequired with code_strategy=next. Total code length including the prefix, 1–15. ?probe=1 reports what this install uses.
check_duplicatesflagopt0 = skip the three duplicate probes. On by default.
allow_duplicateflagopt1 = insert even though a duplicate matched. The matches are echoed back in duplicates_overridden.
debugflagopt1 = per-query timing / size in a _debug block.

Body (JSON). Any subset of the whitelisted columns: title initial name surname company_name Trading_Entity_Name Business_Title Attention DOB sex marital_status · bus_phone prv_phone mob_phone fax_no email_address Email_Address_2 · street street_2 city state pcode County country · postal_street_1 postal_street_2 postal_city postal_state postal_pcode Postal_County postal_country Work_Pcode · privacy_service privacy_marketing privacy_other Privacy_Parts Privacy_3rd_Party ConsentDate ConsentExpiryDate · contact_type class Account_Class fleet_no dealer_no rta_no driver_lic_no Driver_Lic_Expiry_Date · abn tax_exempt_no Tax_Exempt_Type Tax_Region Tax_Status GST_Reg_Flag company_acn · Note creationSource. Plus contact_code itself under the default strategy. Anything not on this list is silently dropped, never written.

json
{ // 201 — created
  "status": "ok", "action": "created",
  "contact_code": "C0004183", "code_strategy": "supplied",
  "fields_written": ["name", "surname", "mob_phone",
                      "email_address", "privacy_service", "creationSource"],
  "rows_affected": 1, "duplicates_overridden": null,
  // keep this code — see the note
  "note": "CONTACT only. No ArMaster row was created…" }

{ // 409 — somebody already looks like this. NOTHING was written.
  "error": "Conflict",
  "duplicates": [{ "contact_code": "C0004182",
                    "display_name": "John Smith",
                    "mob_phone": "0821234567",
                    "email_address": "jsmith@example.co.za",
                    "city": "Durban",
                    // which of the three probes hit
                    "matched_on": ["mobile", "name"] }],
  "wrote_anything": false }

{ // ?probe=1 — the panel that decides your code strategy
  "total_contacts": 48213,
  "code_lengths": [{ "code_length": 8, "contacts": 48211 },
                    { "code_length": 6, "contacts": 2 }],
  "newest_contacts": [{ "contact_code": "C0004182", "surname": "Smith",
                       "Creation_Date": "2026-08-30 14:02:11",
                       "creation_source": "" }],
  "wrote_anything": false }
PATCH customer_contact.php { acc_no, …fields }

Updates whitelisted contact fields for the account. Supply acc_no plus any subset of editable fields. Anything not whitelisted is ignored.

FieldTypeRequiredDescription
acc_nointegerreqDebtor account to update
email_addressstringoptValidated as an email
bus_phone / mob_phone / fax_nostringoptPhone fields
street, city, state, pcode, countrystringoptBilling address
postal_*stringoptPostal address fields
title, name, surname, company_namestringoptIdentity fields
Add ?dry_run=1 to return the exact SQL + bound params without writing.
json
{ "status": "ok", "acc_no": 1001,
  "fields_updated": ["email_address", "city"],
  "rows_affected": 1 }
POST customer_note.php { acc_no, note, mode }

Appends a timestamped note (default) or replaces the note entirely.

FieldTypeRequiredDescription
acc_nointegerreqDebtor account
notestringreqNote text
modestringoptappend (default) or replace
json
{ "status": "ok", "acc_no": 1001, "mode": "append",
  "rows_affected": 1 }

Auto-IT One · Auto-IT South Africa · API v3.7 · Read + Write