Build a "closed ROs where tech FLAGGED hours were below ACTUAL clocked time" report (unpaid flat-rate tech time) for any Tekion store, per-tech per-RO grain. Uses two internal endpoints discovered 2026-07-30 - /ro/v1/{id} carries per-tech flag entries (flagTimesWithPayDay) and clockDetails/fetch carries per-tech actual punches. Also documents the repair-orders:search pageNumber-IGNORED pagination trap and its closedTime-bisection fix. Verified SV (826) Jul 1-15 2026.
Install
npx skillscat add joecastelino/jay-skill-pack/tekion-flag-vs-actual-hours-report Install via the SkillsCat registry.
Tekion — Flagged Hours Below Actual Clocked Time (per RO, per tech)
When to use
Joe asks "pull closed ROs where tech flagged hours were below actual time" — i.e.
flat-rate techs who clocked wrench time on an RO but were never flagged (= never
paid) for it. Different from the opcode-grain over-clock report
(tekion-clock-time-by-opcode): this is per-RO per-TECH, joining two RO-level
internal endpoints. Zero OpenAPI quota except the closed-RO list.
The two data sources (KEY DISCOVERY 2026-07-30)
Both need captured axios headers (see Session refresh below). Basehttps://app.tekioncloud.com.
FLAG hours per tech per op:
GET /api/service-module/u/ro/v1/{roId}—
note the /v1/ segment; the plain/ro/{roId}variant does NOT carry flags
(only laborTimeInSeconds/billingTimeInSeconds). Path:data.jobs[].operations[].techIdWithBillingTimes[]each hastechId+flagTimesWithPayDay[]withflagTimeInSeconds,flagTime(epoch ms),flagHourType(MANUALLY_ADDED/...),flaggedByUserId,flagHourAdjustmentReason. Also useful:referenceHoursForFlagging
(ACTUAL_HOURS),limitFlagHrToBillHr,equateFlagHrToBillHr,jobs[].autoFlagTechnicians.ACTUAL clocked per tech:
POST /api/service-module/u/ro/clockDetails/fetch?allUsers=true
body{"roId": <docId>, "jobIds": [<all job ids>]}→data[]one doc per
tech-per-op withuserId,clockedTimeInSeconds,clockTimes[]punch detail
(inTime/outTime/roundedOffTimeInSeconds/adjustmentReason). Skipdeleted:true.
Found by arming an XHR hook on :9223 and opening the RO kebab → "RO Clocked
Time" (also fires when opening "Manage Technician" on a job).★ BEST SOURCE — the flag LEDGER in ONE call (found 2026-08-28, BC):
POST /api/service-module/u/reporting/technician/breakdown— same body shape as
the Tech Performance call (reportName: FLAG_TIME_REPORT,reportGroup: FLAG_REPORT,filters: [{field:"payDay",operator:"BTW",values:[lo,hi]}, {field:"techId",operator:"IN",values:[uuid]}],pageInfo:{start:0,rows:1000}).
Returns one lineItem per flag entry already joined to the RO:roId, roNo, jobNumber, operationId, opcode, payType, make, payDay, flagTime, flagTimeInSeconds, flagHourType, flagHourAdjustmentReason, flaggedByUserId, assignedBillingTimeInSeconds, operationBillingTimeInSeconds, referenceHoursForFlagging, wagePerHour, laborSale/Cost/GrossAmount, jobConcern.
This replaces the whole/ro/v1/{id}fan-out for flag data — 1 call per tech
instead of ~140 RO calls (~3 min), and it sums EXACTLY to the Tech PerformanceflagTimeInSeconds. Still use/ro/v1/{id}when you need per-techbillingTimeInSecondson the op,autoFlagTechnicians, or who-else-is-on-the-job.
Old note (superseded): "flags ONLY live inside /ro/v1/{id}" — false. Sibling
guesses /reporting/technician/detail and /drilldown DO 404; /breakdown is real.
FASTEST PATH — the flag LEDGER endpoint (discovered BC 2026-08-28)
Before fanning out over ROs, try this. It replaces steps 2-4 for flag data and is
~100x cheaper (2 calls instead of 300):
POST /api/service-module/u/reporting/technician/breakdown — same body as the
Tech Performance aggregate (reportName: FLAG_TIME_REPORT, reportGroup: FLAG_REPORT, filter payDay BTW [lo,hi], optional techId IN [...],pageInfo.rows up to 1000). Returns one row per individual flag entry with:roId, roNo, jobNumber, operationId, opcode, payType, make, payDay, flagTime, flagTimeInSeconds, flagHourType (AUTO_ADDED/MANUALLY_ADDED), flagHourAdjustmentReason, flaggedByUserId, referenceHoursForFlagging, assignedBillingTimeInSeconds, operationBillingTimeInSeconds, wagePerHour, laborSaleAmount/CostAmount/GrossAmount, jobConcern.
Sum of flagTimeInSeconds ties EXACTLY to the aggregate report'sflagTimeInSeconds — use that as the self-check./detail and /drilldown are 404; only /breakdown exists.
The aggregate (/reporting/technician) is literally the endpoint the Tech
Performance (beta) screen calls, so matching it is tautological — the real
cross-check is /breakdown (ledger) + /ro/v1/{id} (RO documents) agreeing
with it, which are different data paths.
referenceHoursForFlagging tells you the store's flag policy. BILL_HOURS
(BC) = auto-flag copies BILLED hours, so Flagged always == Assigned Billed and
NEVER tracks the clock — do not report that identity as a bug. ACTUAL_HOURS
(SV) = flags follow punches.
payDay ≠ work day. A flag lands on the date it was flagged (usually
invoice/close), so a Tech-Performance date range mixes flags for work clocked
weeks earlier with punches from the window. ALWAYS quantify both leaks
separately: (a) ROs punched in-window with zero/low flag, (b) ROs flagged
in-window with no in-window punch. Reporting only the net hides offsetting
errors (BC Tafolla: net −7.45 hid 26.2 unpaid + 30.1 over-flagged).
FAILURE MODE 2 — the flag INDEX silently drops entries (BT 1249, tech 512, 2026-08-28)
When a tech says "RO X, Y, Z are MISSING from my flag hours", it is often NOT a
data-entry problem and NOT the payDay-timing effect. Tekion has two stores of
flag data and they can disagree:
| source | used by | |
|---|---|---|
| INDEX | POST /api/service-module/u/reporting/technician/breakdown (ES) |
Tech Performance (beta), Flag Time Report — what pays the tech |
| TRUTH | GET /api/service-module/u/ro/v1/{roId} → .jobs[].operations[].techIdWithBillingTimes[].flagTimesWithPayDay[] |
the RO document itself |
Flag entries that exist on the RO document but never made it into the ES index
are invisible to the report — the tech worked, the flag posted on the RO, and
the report never pays it.
Reconcile them. Match on the 4-tuple (roId, operationId, payDay, flagTimeInSeconds);
use a collections.Counter and decrement, because a single operation legitimately
carries multiple flag entries (including 0-second reversals). Anything left over
on the TRUTH side is a dropped entry.
Scripts: bt512_flag_index_gap.py (clock-driven RO discovery) andbt512_flag_index_gap_wide.py (adds OpenAPI repair-orders:search onmodifiedTime GTE so ROs the tech never PUNCHED on are still checked).
PITFALL — clock-driven RO discovery misses flag-only ROs. The first pass only
inspected ROs the tech had TECH_CLOCK punches on, which hid RO 151197 (1.80 h
flagged, zero punches by that tech). Always union the punched-RO set with an
OpenAPI repair-orders:search sweep of the window.
PITFALL — /breakdown 500s on a roNo filter. Only payDay + techId
filters are accepted. To check one RO, pull the tech's whole window and filter
client-side.
Diagnostic tell that it is an index drop and not timing: the missing entries are
interleaved in time with entries that DID index (so it is not a cutoff), they are
all AUTO_ADDED, and re-reading the RO document shows the flag present with a
valid payDay inside the report window. Nothing in the UI can fix this — the
flag is already correct on the RO. It needs a Flag Hours Report adjustment to
pay the tech, plus a Tekion ticket for the indexing defect.
⚠️ RESOLVED 2026-08-31 — it was index LAG, not a permanent drop. RE-TEST BEFORE FILING.
Re-ran the identical reconciliation on 2026-08-31, same Aug 16–27 window, same
BC techs that had confirmed drops. Every dropped entry had backfilled into the
index on its own:
| store | emp | tech | 8/28 dropped | 8/31 re-test |
|---|---|---|---|---|
| BC 1251 | 5576 | Victor Tafolla | 4 entries / 2.40 hrs | 0 / 0.00 — 45 = 45 entries, 58.50 = 58.50 hrs |
| BC 1251 | 410 | Craig Holman | 2 entries / 3.40 hrs | 0 / 0.00 — 68 = 68 entries, 52.80 = 52.80 hrs |
| BT 1249 | 512 | Luis Vasquez Melchor | 4 entries / 3.80 hrs | 0 / 0.00 — 63 = 63 entries, 62.40 = 62.40 hrs |
3 of 3 techs across BOTH affected stores cleared on re-test — so this is a
platform-wide indexing lag, not a per-store config problem, and it self-heals in
roughly 3 days. All 9.60 hrs / ~$480 landed without intervention.
Tell that it self-healed: the tech's NATIVE flagged total rose by exactly the
dropped hours (Tafolla 56.10 → 58.50, Holman 49.40 → 52.80, Vasquez Melchor
58.60 → 62.40). So the tech gets paid with NO Flag Hours Report adjustment — do not post a manual adjustment on a
fresh drop, you will double-pay him. Same story as the Advisor Performance index
lag re-tested the same morning (see tekion-gross-not-posting-to-advisor).
Standing rule now: a same-week index gap is presumed LAG. Before promising a
remediation or drafting a Tekion ticket, (1) say it's likely to self-heal, and
(2) re-run the reconciliation 2–3 days later. Only entries still absent after
several days are a real defect. Re-test is cheap: build_tech_perf_package.py
for the same tech/window, read the NOT IN INDEX line.
Corroborate across stores before calling it a Tekion defect (the standing
fleet-comparison rule — a single store's oddity has repeatedly turned out to be
local config). Originally observed at two of seven stores, 3 of the first 4 techs
checked (2026-08-28, window Aug 16–27) — all since cleared:
| store | emp | tech | dropped entries | hours | $ @ wage |
|---|---|---|---|---|---|
| BT 1249 | 512 | Luis Vasquez Melchor | 4 | 3.80 | — |
| BC 1251 | 5576 | Victor Tafolla | 4 | 2.40 | $124.80 @ $52 |
| BC 1251 | 410 | Craig Holman | 2 | 3.40 | $176.80 @ $52 |
| BC 1251 | 192 | Michael Barks | 0 | 0.00 | clean |
Signature to quote in the Tekion ticket — it has held for every hour-bearing
drop across both stores: pay type WARRANTY, flagHourType AUTO_ADDED,
flagged in the back half of the pay period (08/26–08/27), present and valid
on the RO document, absent from the ES index. Zero-hour entries drop too
(Tafolla RO 101268, customer pay) — same index failure, no pay impact, so count
entries dropped separately from hours dropped.
Drops are NOT confined to closed ROs — Holman's 2.40 hr drop was on RO 101329IN_PROGRESS, his 1.00 hr drop on RO 100980 INVOICED. One tech's drop list can
therefore need BOTH fix paths; always print the RO status per dropped entry and
split the remediation list (tech-time modal vs Flag Hours Report adjustment)
rather than assuming a single path per tech.
⭐ TWO RENDERERS — the clean one is usually what he actually wants
Joe's follow-up to the corrected version was "write it so it doesn't look like
it's corrected" (2026-08-28). A report headed "CORRECTED" with a
Tekion-vs-Correction-vs-Corrected table is an internal diagnostic — it advertises
that the DMS was wrong and it can't go to a tech, a manager, or a pay file.
| script | output stem | use for |
|---|---|---|
render_tech_perf_corrected.py |
tech_perf_corrected_bt_<emp>_<date> |
proving the gap, Tekion ticket evidence |
render_tech_perf.py |
tech_perf_bt_<emp>_<date> |
the deliverable — routine report, one set of numbers |
Both read the SAME package JSON. The clean one merges indexed + missing into a
single ledger and never mentions the distinction: native 13-column Tech Performance
layout (TOTAL row on top), KPI cards = Flagged / Attendance / Proficiency /
Efficiency, a Labor Summary, then the flag detail. Page 2 = flagged-vs-clocked by RO
plus per-RO job detail with concerns. Zero words like corrected / missing /
recovered / defect. Grep the HTML for those before sending.
Default to building the clean one and keep the diagnostic version as backup
evidence — don't wait to be asked twice.
Two things the clean renderer must handle that the diagnostic one didn't:
- Labor cost is
$0on entries read from the RO document (nolaborCostDetail).
Tekion derives it asflag_hrs × wage, so derive it the same way — otherwise labor
gross is overstated by the full tech-pay amount. Sanity check: Labor Cost should
equal Tech Pay ($490.09 vs $490.10, rounding). - Concern text arrives mojibaked (UTF-8 bytes read as latin-1):
â\x80\x93for an
en-dash,â\x80\x99for a curly apostrophe. Round-trip withs.encode("latin-1").decode("utf-8")inside a try/except.
DELIVERABLE — the CORRECTED Tech Performance report (BT 512, 2026-08-28)
Once you've proven an index drop, Joe's next message is "can you generate a
corrected version for me?" — same shape as the advisor closed-performance
reports. Build it, don't just describe the gap.
cd /home/itadmin/tekion-reports
python3 render_tech_perf_corrected.py \
data/bt-<emp>-corrected-<YYYY-MM-DD>.json "<Tech Name>" <emp> <YYYY-MM-DD>Outputs out/tech_perf_corrected_bt_<emp>_<date>.{png,pdf,csv}.
Input JSON keys: native (the lineItem from /reporting/technician), indexed
(that day's /breakdown rows), missing (recovered entries), clock ({roNo: hrs}
from TECH_CLOCK), store_name, wage_per_hour (CENTS), tech, date.
Layout: 4 KPI cards (Flagged-Tekion / Flagged-Corrected / Proficiency / Tech Pay
Impact in dollars — that last one is what makes it actionable), a Tekion-vs-
Correction-vs-Corrected metric table, the recovered-entries table with a Fix Path
column, then page 2 = the full flag ledger with an In Report? yes/NO — dropped
column plus clocked-vs-flagged by RO.
Fix Path rule: RO status INVOICED/CLOSED → Flag Hours Report adjustment;
anything else (READY_FOR_INVOICE, IN_PROGRESS) → tech time modal on the RO.
Two data traps that produced wrong numbers on the first render
- Don't reuse a
payDayyou carried in from a hand-built dict. RO 151197's
timestamp printed as 09:27 instead of the true 13:27. Re-readpayDayfrom the
RO document'sflagTimesWithPayDay[], matching onflagTimeInSeconds. - PRORATE labor sale to the flagged share.
sum(labor.preSplits[].amountToSplit)
is the WHOLE operation's sale. RO 149531 flagged 0.10 hr of a 0.70 hr operation —
charging the full $169.83 overstated the recovery by $145. Usesale * flag_sec / op_billing_sec. Correct total was $919.86, not $1,065.43.
Report both flagTimeInSeconds and operationBillingTimeInSeconds in the ledger so
a partial flag is visible rather than looking like a discrepancy.
Native metric definitions (confirmed against /reporting/technician)
- Efficiency % = flag / clocked · Proficiency % = flag / attendance
- Unapplied = attendance − flag (goes negative when corrected flag > attendance —
that is real and worth calling out, not a render bug) departmentId NIN [<dealer>_department_5]is the screen's DEFAULT filter. With it
applied BT 512 showed 4.30 flag hrs; without it, 7.80. If your native total doesn't
match what the user sees, this filter is why — capture__post_bodyand match it.
PITFALL — never let a report fall back to another store's logo
logo_0.png and logo_st.png in ~/tekion-reports/ are both Stevens Creek Toyota,
despite the neutral-looking logo_0 name. Many older renderers hardcode logo_0.png
as a generic default. On 2026-08-28 a Blackstone Chevrolet Tech Performance report
went to Joe with the Stevens Creek Toyota logo in the header — he caught it immediately.
Rules:
- SCT is the ONLY store with a verified logo asset on disk. There is no BC/BT/TL/SV/VC/AR
logo file, and the AMG signature assetamg-dealer-logos.jpgonly holds generic
MANUFACTURER marks (Chevy bowtie, Toyota oval), not dealership logos. - Dealer websites are Cloudflare-403'd to both curl and the browser tool — you cannot
scrape a logo at run time. - So: render a text wordmark in the store's brand colors for every non-SCT store.
render_tech_perf.pyhas a_brand(store)helper returning(line1, line2, color1, accent)and buildsMARK(img when SCT, wordmark otherwise).
The accent color also drives the header rule andh2bars via a--accentCSS var. - CSS is injected with
.replace("__ACCENT__", ACCENT), not%-formatting —
the stylesheet containswidth:100%which breaks%substitution. - Always
vision_analyzethe rendered PNG and explicitly ask "what dealership branding
is in the header?" before emailing. The numbers being right does not make the report right.
Method
Refresh session + headers (captures die ~2h): run
/home/itadmin/tekion-auth/login.py --force, then/home/itadmin/tekion-reports/capture_svd_actualtime_sv.py(any internal
capture works — headers are session-wide). Strip:-pseudo headers, save to/tmp/sv_headers_fresh.json. Probe with one request; 401 = stale.Get the RO population — DON'T use OpenAPI if you can avoid it.
★ PREFERRED (2026-08-28): group TECH_CLOCK byroId, notroNo. The
visibility-dashboardgenerate-summary-reportTECH_CLOCK datasource (see skill
tekion-clock-time-by-opcode for the request shape) acceptsgroups[0].field = "roId", and that value IS the RO documentId — the exact key/ro/v1/{id}
andclockDetails/fetchneed. Zero OpenAPI calls, zero quota, no pagination.
Group byroNoonly when you want human-readable output; grab both if needed.
Other useful group fields:opcode,inTimeDate,techId,payType.BURNED AT BC 2026-08-28: the documented OpenAPI path below returned
{"data":{"results":[]},"meta":{"totalCount":0}}for BC RO numbers that
demonstrably exist —repair-orders:searchwithdocumentNumber IN [...]found
NOTHING at dealer 1251, and the response shape isdata.results(a dict), notdata(a list), so naivefor d in r["data"]crashes with'str' object has no attribute 'get'. Don't build on it.(Legacy path, only if you truly need closed-RO-list semantics) Closed-RO list
via OpenAPIrepair-orders:searchwith closedTime GTE/LTE + status IN
[CLOSED, INVOICED]. PAGINATION TRAP:pageInfo.pageNumberis IGNORED —
every page returns the SAME first 20 rows (pageSize capped at 20 despite asking
100), andmeta.nextPageTokenDRIFTS OUTSIDE the closedTime filter. FIX =
closedTime bisection: recursively split [lo,hi] untilmeta.totalCount <= len(results), concat, dedupe on documentId.Fan out both internal calls per RO (0.25s pace, 429 backoff), aggregate
per techId:flag_s= sum of all flagTimeInSeconds across ops,actual_s=
sum of clockedTimeInSeconds. Keep rows whereactual - flag >= 180s(0.05h
noise floor).Resolve tech names in bulk:
POST /api/rosearchservice/u/visibility-dashboard/lookup/resolve-by-id
body{"lookupByIds":[{"lookUpAsset":"TECH_ID","ids":[...]}]}(same headers).Rank by gap; present per-tech summary table + per-RO detail with opcodes.
★ TURNKEY — the generic package builder (built BC 2026-08-28, use this first)
Stop writing a bespoke <store><emp>_flag_gap.py per request. One script assembles
the complete package JSON both renderers consume, for ANY store / tech / date range:
cd /home/itadmin/tekion-reports
python3 build_tech_perf_package.py --dealer 1251 --tech-emp 5576 \
--from 2026-08-16 --to 2026-08-27 --store-name "Blackstone Chevrolet"
# -> data/tech-<dealer>-<emp>-<from>_<to>.jsonIt does all four sources in order: resolve employee# → tech UUID, /reporting/technician
(native), /breakdown (index ledger), TECH_CLOCK by roNo, then the RO-document sweep
(punched ROs ∪ OpenAPI modifiedTime window) and prints the NOT IN INDEX table.
Requires /tmp/tekion_tech_headers_<dealer>.json from capture_tech_report_headers.py.
Then render — the renderer takes an optional 5th arg = END date for a range:
python3 render_tech_perf.py data/tech-1251-5576-2026-08-16_2026-08-27.json \
"Victor Tafolla" 5576 2026-08-16 2026-08-27
# -> out/tech_perf_<dealer>-<emp>-<from>_<to>.{png,pdf,csv}Range mode adds a Daily Summary table (entries / ROs / flag hrs / labor sale / tech
pay per flag date), switches the header chip to "Flag Dates", and stamps %m/%d %H:%M
on every ledger row instead of bare time. Single-date behaviour is unchanged when the
5th arg is omitted. The output stem is derived from the package filename, so two techs
never overwrite each other.
Turnkey scripts
Any-store header capture (built 2026-08-28, use this — the _sv ones are
hardcoded to dealer 826):/home/itadmin/tekion-reports/capture_tech_report_headers.py <dealerId> "<Dealer Name RX>"
→ /tmp/tekion_tech_headers_<dealer>.json (headers + __post_body + sample resp).
Headless Playwright w/ storage_state; skips the dealer-switch iflocalStorage.currentActiveDealerId already matches; navigates /core/reports →
Service → Tech Performance (direct goto does NOT fire the data request). ~60s.
Sibling capture_tech_drilldown.py <dealerId> "<Dealer RX>" "<Tech Name RX>" clicks
a tech row and dumps every /api/ POST — that's how /breakdown was found. Note
the BC report URL is /core/reports/service/tech-performance-v2.
The two-tech audit (BC 2026-08-28):/home/itadmin/tekion-reports/bc_flag_audit_two_techs.py → writesdata/bc-flag-audit-192-5576.json; recon output data/bc-flag-recon-192-5576.json.
Edit the TECHS dict + window constants. Uses TECH_CLOCK-by-roId (no OpenAPI).
Older SV store-wide version:/home/itadmin/tekion-reports/sv_flag_below_actual_h1jul.py →data/sv-flag-below-actual-h1jul.json. Adapt: date window (Pacific tz!), store =
re-capture headers at that store (internal headers carry dealerId/tek-siteId —
switch dealer in the capture browser first, or swap dealerId andtek-siteId: -1_<id> headers like deferred_services_90d.py).
286 ROs ≈ 5 min; run background with notify_on_complete.
Note (2026-08-28): login.py (no --force) reported ALIVE with 41163 min left
and the capture worked first try — don't reflexively --force, probe first.
Reading the result (SV Jul 1-15 2026 reference)
286 closed ROs → 22 tech-RO rows, 15.8 hrs unpaid gap. Montgomery Hirsch 5.44
(one RO, 371607 MECDIAG, 4.41 clocked / 0 flagged = a third of the whole gap);
several techs with actual>0 and flag=0.00 = never flagged at all. Frame for Joe:
concentrated in a few techs/ROs, not store-wide; diag time is the usual leak.
Fix path for open ROs = flag on the RO tech-time modal; for CLOSED ROs =
Reports → Flag Hours Report → Add Adjustment (KB0014998, no reopen needed).
On the open-RO tech-time modal: Flagged hrs + Flag date + Reason + Flag Type are
all required per tech row; manual entry switches that row off auto-flag for the
RO (expected). Caveats: (1) flag ≠ bill can BLOCK invoice close if the store's
pre-invoice rule treats it as error (warning = fine) — check Service Settings if
close goes red; (2) per-tech Labor Cost $0.00 in the modal = wage type missing
on the employee record → flagged hours post at ZERO cost to RO/GL even though
payroll pays off the report; employee-record fix, NEVER touch without Joe's
explicit OK. Verified good example: SV RO 372190 line G — flagged 4.15 to match
actual clocked, op labor cost went $0 → $230.70 once fixed.
Single-tech deep-dive variant (Joe follow-up, verified Tualla SV 2026-07-31)
"The flagged hours look suspect / don't match" ticket (BC 2026-08-28)
Joe's framing is usually "tech flagged hours are suspect of not matching, compare to
Tech Performance (beta)" with 1-2 employee numbers, not names.
Run order that answers it in ~4 tool calls (proven BC 5605, 2026-08-31):
build_tech_perf_package.py --dealer <D> --tech-emp <N> --from --to --store-name—
prints native vs index vs truth and theNOT IN INDEXcount. If that's0 entries,
say so first and plainly: the report is not losing his hours.- Diff flagged-vs-clocked per RO from the package (
indexed+clock), split into
under/over buckets and print both totals — never just the net. - For every under-flagged RO, fetch
GET /api/service-module/u/ro/v1/{roId}and printstatus+ per-oppayType/billingTimeInSeconds/flagTimesWithPayDay. That one
dump classifies every gap into the bucket table above and is where the real answer lives. render_tech_perf.py(clean renderer) → vision_analyze the PNG for branding → deliver.
Don't stop at step 1. "Index is clean" is not an answer to a manager who is staring at
double-digit unapplied hours — step 3 is what turns it into something he can act on.
GET /openapi/v4.0.0/users (pageSize 100) →employeeDetails.employeeDisplayNumber + userNameDetails.completeNames[DISPLAY_NAME]
userRoleDetails.primaryRole.persona. PAGINATION GOTCHA: the next-page param isnextFetchKey, NOTfetchKey— passingfetchKey(orpageToken/fetch_key)
is silently ignored and you re-fetch page 1 forever (I "pulled 800 users" that
deduped to 100 and missed both targets). 375 users at BC = 4 pages./openapi/v4.0.0/employees= 404, doesn't exist.
NoteemployeeIdis inconsistent (1251_192vs a UUID) — key onemployeeDisplayNumber.
Three more /users shape traps, all hit at BC 2026-08-28 (each one silently
returns the wrong answer instead of erroring — budget a debug pass here):
- It is CURSOR-paginated, and
pageNumberis accepted-and-ignored. SendingpageInfo.pageNumber1,2,3… returns the identical first 100 users every time, so
the loop "succeeds" and both target techs are simply absent. The ONLY way forward
ismeta.nextFetchKey→ next request'snextFetchKey. Stop when it's absent.
Self-check: assert the deduped user count grows each page. datais a bare LIST at some dealers, a{"results":[...]}dict at others.
Handle both:rows = d if isinstance(d, list) else d.get("results", []).completeNamesis a LIST of{nameType, value}at BC, a dict elsewhere.completeNames["DISPLAY_NAME"]throwsTypeError: list indices must be integers.
Normalize before reading.
Step 1 — reproduce the report number first. Pull/reporting/technician FLAG_TIME_REPORT for the window with a techId filter, then
sum the /breakdown ledger independently. At BC both tied EXACTLY (63.50 and 56.10).
Say that out loud: the report is not miscounting. This is the Joe STEP-ZERO rule —
prove the symptom reproduces before diagnosing.
Step 2 — the real answer is almost always the payDay-vs-punch-date mismatch.flagHourType: AUTO_ADDED + referenceHoursForFlagging: BILL_HOURS +job.autoFlagTechnicians: true means Flagged Hours is BILLED hours by design and
never equals the clock. Flags land on payDay = the date flagged (invoice/close),
NOT the day wrenched. So any Tech Performance date range mixes flags for work
clocked weeks earlier with punches that will flag later. Present it as:
- clocked-but-never-flagged (unpaid tech time) — the actionable leak
- flagged-above-clock (billed > wrench, normal on warranty/flat-rate)
- flags with no in-window punch (the windowing artifact itself)
A tech who turns ROs same-week (Barks: 62.33 clk / 63.50 flag, max ±1.87/RO) looks
clean; a tech sitting on long open ROs (Tafolla: 26 hrs punched with ZERO flag,
incl. one RO at 17.46) looks broken — same store, same settings, different WIP age.
Step 3 — always surface the manual adjustments. FilterflagHourType != "AUTO_ADDED". MANUALLY_ADDED with a negativeflagTimeInSeconds = someone clawed hours back by hand (BC: two −1.00 entries on
8/18). Negative AUTO_ADDED rows are billing reversals. Joe wants both directions
reported; negatives are the ones that start conversations.
⭐ WHEN THE INDEX IS CLEAN — triage the leftover gap, don't force a defect
Two techs at the same store, same window, same settings will NOT both have the
index defect. At BC 2026-08-28: Tafolla was short 2.40 hrs / 4 dropped entries;
Barks tied EXACTLY — 85 entries for 85, 63.50 hrs for 63.50. Say the clean
result plainly and just as loudly as the defect. Joe's next question is then
"so what discrepancy DOES he have?", because the Tech Performance screen still
showed Barks 3.92 hrs unapplied (attendance 67.42 / clocked 62.33 / flagged 63.50).
Walk the clocked-RO list and bucket every unflagged RO into one of four causes.
Only ONE of them is a Tekion bug:
| bucket | tell | verdict |
|---|---|---|
| payDay AFTER the window | flag exists on the RO doc, payDay = day after --to |
timing, not lost — lands next pay period |
| payDay BEFORE the window | clocked in-window, but the flag payDay predates --from |
already paid — NOT a leak (BC 100495: 1.52 clk in-window, 4.50 flagged 8/14) |
| RO still open | status IN_PROGRESS/READY_FOR_INVOICE |
not billed yet; flags post at billing |
| billed but on HOLD | status HOLD, op has real billingTimeInSeconds, flags: [] |
pending swing — flags all at once when it invoices |
| invoiced/closed with ZERO labor billed | op billSec: 0, flags: [], but real clock time |
store-process issue — nothing to flag against |
| flag on doc, absent from index | doc has a valid in-window payDay, /breakdown doesn't |
the Tekion index defect — presume LAG, re-test in 2–3 days |
The two buckets that actually move the number (BC 5605 Jared Weimer, Aug 16–30):
billSec: 0on an INVOICED warranty op was 7.65 of 10.57 unapplied hrs — RO 102152,ELECTDIAG/WARRANTY invoiced at 0.00 billed hrs with 7.65 clocked. Auto-flag copies
BILLED hours, so a zero-billed op flags zero forever. This is the single most common
real leak once the index is clean, and it is a store conversation (bill the diag
time before invoicing), not a Tekion ticket.- HOLD tickets are a loaded spring. RO 101483 sat on HOLD with a 20.5-hr WARRANTY
MISCop and zero flags; the tech showed 1.00 flagged / 3.44 clocked. Call these out
explicitly — the next period will look inflated for reasons that have nothing to do
with that period's work, and Joe/the manager will ask about it.
⚠️ The window materially changes the verdict — confirm it before reporting. Same tech,
same store: Aug 16–27 read proficiency 100.50% / unapplied −0.34; extending to
Aug 16–30 read 87.76% / unapplied +10.57. Three days added one un-billed RO and
flipped the story from "clean" to "10.5 hrs unapplied." Always state the exact window in
the answer, and when Joe says "run it up till yesterday," re-run rather than extrapolating.
Barks: RO 101947 CLOSED 2.86 clocked → flagged 2.70 on 08/28 (one day late);
RO 101249 IN_PROGRESS 1.34; RO 101304 CLOSED 0.49 with TPS/INTERNAL andCONCERN/WARRANTY both at billSec 0 — the only genuine gap, and it belongs in a
conversation with the store, not the Tekion ticket.
Everything else was normal flat-rate outperformance (clocked 3.63 / flagged 5.50,
12.95 / 14.50, 2.41 / 3.50 → 101.9% efficiency). Beating flat rate is not a
discrepancy — label it so nobody chases it.
Fetch the per-RO evidence with clock_by("roId", …) for the RO universe, thenGET /api/service-module/u/ro/v1/{roId} per candidate. NotePOST /api/rosearchservice/u/ro/search is a 404 — that endpoint does not exist;
RO lookup goes through the clock report + /openapi/v4.0.0/repair-orders:search.
Pitfalls
- NEVER hand-roll the TECH_CLOCK
generate-summary-reportbody — you get HTTP 400.
Writing a plausible{"reportId","dataSource","groups","filters","defaultFilter","pageInfo"}
payload from scratch fails every time. The ONLY working shape is: GET/api/rosearchservice/u/visibility-dashboard/report-definition/{CLOCK_REPORT_ID}?locale=en_US,copy.deepcopy(DEF["customEsRequests"][0]), overwritefilters(withkeyANDfield
on each) +groups[0].key/field/rows, and POST it wrapped in a LIST[r]. That's
whatclock_by()inbuild_tech_perf_package.pydoes — reuse that function, don't
reinvent it. lookup/resolve-by-idwithlookUpAsset: "USER_ID"returns HTTP 500. It works forTECH_IDbut not for resolving aflaggedByUserId. Use the public OpenAPI instead:GET /openapi/v4.0.0/users/{uuid}→userNameDetails.completeNames[](a LIST of{nameType,value}), plususerRoleDetails.primaryRoleandemployeeDetails.employeeDisplayNumber. That's how BC's lone manual flag was traced to
Phillip Stafford, Shop Foreman, emp 5577.- Always name who posted a
MANUALLY_ADDEDflag. It's usually the only hand-keyed
entry in the period and it's the one line a manager should validate — especially with a
blankflagHourAdjustmentReason. BC 100211: 20.00 hrs keyed by the Shop Foreman against
a 27.0-hr op, $4,980 labor, no reason text. Not necessarily wrong, but always worth surfacing. /ro/v1/{id}response shape: RO header fields (roNo,status,closedTime,primaryAdvisorId,documentNumber) are nested one level down indata.ro, NOT ondata—dataitself only holdsjobs/recommendations/jobParts/clockTimes/invoices/id/ro. Readingdata.get("documentNumber")silently yieldsNoneand your report printsRO ?
for every row (cost me a full re-run)./reporting/technicianaccepts ANYreportNamestring. I probed 6 invented
names (FLAG_TIME_DETAIL_REPORT, FLAG_HOURS_REPORT, TECH_FLAG_DETAIL...) and every
one returned 200 with the SAME 2 rows. Unknown reportName is ignored, not
rejected — "it worked!" proves nothing. Verify the row SHAPE changed.
Same trap:/api/service-module/u/settingswith unknownrequestOptionsreturns{}rather than erroring; don't infer "setting absent" from it.- A tech can appear in clockDetails but have NO techIdWithBillingTimes entry
(clocked on a job they were never assigned/flagged on) — union the tech sets,
missing flag entry = 0. Conversely a tech row can readbill 0.00 / flag 0.00
on an op that IS billed (BC RO 100739 job4 CONCERN billed 4.00W) — that's the
signature of "he clocked it, someone else's hours carried the job." - Times in SECONDS; OpenAPI $ in CENTS.
- Reconcile totals BOTH ways before reporting. A tech's net (clocked − flagged)
can look tiny while hiding two large offsetting errors — Tafolla netted only
−7.45 hrs but that was 37.5 under + 30.1 over. Always print under/over/zero-flag
buckets separately, never just the net. - Don't run a wide "chase the missing 0.60 hr" scan before checking arithmetic: I
fanned 225 extra ROs looking for stray flags and found 0 — the 55.50 vs 56.10 gap
was just entries the narrower discovery window had excluded, which/breakdown
returns for free. Use/breakdownFIRST and the gap never appears. - 401 on replay = stale capture; login.py can report ALIVE while storage state
is dead — use--forcethen re-capture. - Don't trust the first "0 results" run: check
meta.totalCountvs rows
returned before believing the window is empty.