Split the DealerDetail advisor dashboard by Tekion persona (SERVICE_ADVISOR vs an "Others" bucket for cashiers/warranty clerks/service managers/techs), backfill the persona column from the Tekion users API, and fix duplicate-advisor rows caused by null-name pre-scope-upgrade pulls. Use when advisor numbers look polluted by non-advisors, when you see an "Unassigned"/null advisor, when personas are 0/N populated, or when the same Tekion userId appears under two Advisor rows.
Install
npx skillscat add joecastelino/jay-skill-pack/dealerdetail-persona-and-dedup Install via the SkillsCat registry.
DealerDetail — Advisor Persona Split & Dedup
Repo & env (CRITICAL — get these right first)
- Real repo path:
/home/itadmin/dealer-detail(NOT~/dealerdetail, which is a non-git specs dir). For this agent~≠/home/itadmin. - App lives in
apps/web. Run scripts from there. mainauto-deploys to Vercel production. Merging = prod deploy. Never push to main casually.- Git creds:
git -c credential.helper='store --file=/home/itadmin/.git-credentials' push origin main - Node/tsx:
/home/itadmin/.hermes/node/bin(node v22). - tsx run pattern (the Tekion client uses react-server conditional imports):
WITHOUTcd /home/itadmin/dealer-detail/apps/web set -a && . ./.env 2>/dev/null && set +a npx tsx --conditions=react-server <script.ts>--conditions=react-serveryou get aTransformError. A bare.mjsimporting the client also fails (ERR_MODULE_NOT_FOUND) unless run from insideapps/web. For pure Prisma queries (no Tekion client), a.mjswithimport { PrismaClient } from "@prisma/client"run fromapps/webworks fine and is faster.
Schema facts (memorize — easy to get wrong)
- Store model: name field is
name, abbreviation field isabbreviation(NOTabbrev), Tekion id istekionDealerId(e.g.americanmotorscorporation_876_0). Find SCT withwhere: { abbreviation: "SCT" }. - Advisor model:
nameRaw(nullable),nameNormalized(the@@unique([storeId, nameNormalized])key),tekionUserId,persona(String?, added by the persona-split feature). - AdvisorDailyMetrics:
@@unique([storeId, advisorId, businessDate]). - AdvisorDailyCommodity:
@@unique([storeId, advisorId, businessDate, commodityKey]).
Persona data path
- Source: Tekion public OpenAPI
GET /openapi/v4.0.0/users/{id}(works for numeric ids AND UUIDs after the scope upgrade Joe enabled). - JSON path:
data.userRoleDetails.primaryRole.persona→ values likeSERVICE_ADVISOR,SERVICE_MANAGER,WARRANTY_CLERK,CASHIER,TECHNICIAN. - In code:
TekionClient.resolveUserDetailed(dealerId, userId)returns{ name, persona, sourceField, raw }.extractUserPersona(raw)does the pluck.
The dashboard split rule
persona === "SERVICE_ADVISOR"→ goes in the advisors leaderboard.- Everything else (including the 3 non-advisor personas, and historically null) → the "Others (non-advisor roles)" bucket. Others keeps all metrics (nothing dropped) so the data is revisitable.
- Files that implement it (already merged):
lib/sources/tekion/client.ts(persona cache + extract),advisors.ts(ResolvedAdvisor {name,persona}),collector.ts+aggregate/aggregator.ts(BOTH persist persona — the aggregator independently re-derives Advisor rows, so it must set persona too or it stays null),lib/server/services/dashboard.ts(DashboardData.others+ partition),app/(app)/dashboard/ui.tsx(Others section +othersColumns/formatPersona).
Task A — Backfill the persona column (when personas are 0/N or partial)
- Write a backfill script in
apps/web(e.g._persona_backfill.ts) that:- finds the store by
abbreviation, grabstekionDealerId, prisma.advisor.findMany({ where: { storeId, tekionUserId: { not: null }, /* optionally persona: null */ } }),- for each:
const d = await client.resolveUserDetailed(dealerId, a.tekionUserId), thenupdatepersona: d.persona, and backfillnameRawif it was null.
- finds the store by
- Run it in the BACKGROUND with
notify_on_complete— the calls sit in 429 backoff if the bucket is exhausted; foreground will time out at 180s. - It is normal for the FIRST 1-2 lookups to FAIL with 429 (
OVERALL_RATELIMIT) if the bucket just emptied, then succeed once it recovers mid-run. Just re-run the script filteringpersona: nullto mop up the stragglers — usually succeeds immediately on the second pass. - Verify: query and bucket into SERVICE_ADVISOR / others / null-persona. Target = 0 null personas.
- Clean up temp scripts (
rmthem) — they are NOT meant to be committed.
Task B — Fix duplicate advisor (the "Unassigned"/null-name bug)
Symptom: the same tekionUserId appears under TWO Advisor rows — one named, one with nameRaw = null and nameNormalized = "UNASSIGNED". Cause: the collector ran BEFORE the API scope upgrade (couldn't resolve the name), so it stored a null-name row keyed on a different nameNormalized; a later pull created a second, named row. The @@unique([storeId, nameNormalized]) constraint allows both because the normalized keys differ. This splits the advisor's metrics across two rows.
Fix (reparent + delete, transactional):
- Find both rows:
where: { storeId, tekionUserId: "<uuid>" }. - Check each row's
advisorDailyMetricsandadvisorDailyCommoditybusinessDates. - Check for date overlap. If the null row and named row have NO overlapping
businessDate, you can reparent cleanly. If they DO overlap, the@@uniquewill reject the UPDATE — in that case delete the older (null-row) metric for the colliding date (the post-upgrade named-row data is authoritative). - In a
prisma.$transaction:advisorDailyMetrics.updateMany({ where: { advisorId: nullRow.id }, data: { advisorId: namedRow.id } })- same for
advisorDailyCommodity advisor.delete({ where: { id: nullRow.id } })
- Verify advisor count dropped by 1 and there are no remaining null-name rows.
(Real example: Angel Gutierrez, UUID ee31e3e9-bba5-4868-8ead-a2464c95eab1, SERVICE_ADVISOR — had a UNASSIGNED ghost row with 06-15 metrics + a tires commodity, plus a named row with 06-17/06-18. No overlap, clean reparent, deleted ghost.)
Tekion 429 budget choreography (the recurring villain)
- Limit ≈ 1,500 calls / 15 min (
OVERALL_RATELIMIT). The whole org/fleet shares it. - A 7-day collect window blows the budget and grinds in backoff for 30+ min — use the default 3-day window (
unset COLLECT_ST_WINDOW_DAYS).sync:st= collect→aggregate. - If you kill a sync mid-run (SIGTERM/-15), the collector's signal handler usually marks its SyncRun FAILED itself, but verify:
updateMany({ where:{status:"RUNNING"}, data:{status:"FAILED", finishedAt:new Date()} })to reap orphans, or the next run sees a stuck RUNNING row. - Background long ops with
notify_on_complete=true+ watch_patterns["=== SYNC COMPLETE ===","FAILED","OVERALL_RATELIMIT","rosFetched"]. NOTE: buffered tsx/Node output only flushes at exit, and stale watch-pattern notifications from a KILLED process can arrive minutes later — match thesession_idbefore reacting. - Permanent staleness fix = the nightly cron (below). Without it, data drifts 3+ days stale and forces big catch-up pulls that blow the budget.
Nightly cron (the staleness fix)
- Wrapper:
/home/itadmin/dealer-detail/scripts/cron-sct-sync.sh(sets PATH to hermes node, loads.env,unset COLLECT_ST_WINDOW_DAYS,npm run sync:st, logs tologs/sct-sync-nightly.log). - Crontab line (runs 1:30 AM, offset from the 1:00 Caliber Tekion job to avoid sharing the 429 budget at the same minute):
30 1 * * * /usr/bin/flock -n /tmp/dealerdetail-sct-sync.lock /home/itadmin/dealer-detail/scripts/cron-sct-sync.sh >> /home/itadmin/dealer-detail/logs/sct-sync-nightly.log 2>&1 - Follows the existing Caliber/VI cron pattern (flock + append log).
Closing % — known dead end
recClosingPct is hardwired to 0.00%. The Tekion OpenAPI has no inspection/recommendation/MPVI/declined-jobs endpoint (confirmed across all 266 specs, even post-scope-upgrade). repair-orders:search returns SOLD/COMPLETED jobs only. The denominator (recommended-but-declined work) is ONLY in the Report Builder "Open RO Count" Excel export → needs the tekion-report-builder-scraper skill, not the API. Don't burn time hunting an API endpoint for this.
Pitfalls recap
abbreviationnotabbrev;nameRaw/nameNormalizednotnameon Advisor.- Must use
--conditions=react-serverfor any script importing TekionClient. - Persist persona in BOTH collector AND aggregator.
- First 1-2 backfill calls 429ing is normal — re-run to mop up.
- Check businessDate overlap before reparenting metrics (unique constraint).
- Match
session_idon delayed watch-pattern notifications — don't react to a killed proc's stale output. - Clean up temp
_*.ts/_*.mjsscripts before/after committing.