PSI.UniData.API

REST API gateway providing web application access to the PSI UniData/AFTEC database. Enables modern web apps to query ERP data without direct UniData connectivity.


Overview

Redbook correctness checkpoint (2026-09-08, not deployed)

Local Redbook service changes recognize desktop department codes by multivalue slot, retain compatibility with earlier web Y/N flags, and persist the proper business code on transitions. Unchanged department metadata and unrelated raw record attributes are preserved. Invalid department input is rejected. Priority lookup now matches the desktop: 0-High, 1-Normal, 2-Low.

Regression fixtures are synthetic. Production impact verification and shared atomic concurrency control across desktop/web writers remain outstanding; this checkpoint does not claim deployment or safe concurrent writes.

PSI.UniData.API is a .NET 8 Web API that exposes UniData subroutines via REST endpoints. It uses the U2 Toolkit for .NET for high-performance database access, bypassing the slow WCF/PSI Local Service layer.

Web Apps → REST API → U2 Toolkit → UniData (MRP-PROD)
     ↑                    ↑
  Azure AD           Per-user credentials
FeatureDescription
Production URLhttps://api.progressivesurface.com
Performance~200-300ms for full BOM explosion (vs 5-7 min via WCF)
AuthenticationAzure AD with per-user UniData credential lookup
DeploymentWindows Service on PS-PROXY with auto-deployment
DocumentationSwagger UI at root URL

Repository: ProgressiveSurface/PSI.UniData.API


Production Deployment

Server Details

SettingValue
ServerPS-PROXY.AD.PTIHOME.com
Service NamePSI.UniData.API
Install PathC:\Services\PSI.UniData.API
HTTPS Port443 (wildcard cert: *.progressivesurface.com)
HTTP Port80
DNSapi.progressivesurface.com → 192.9.201.217

Auto-Deployment (GitHub Actions)

The repo has a self-hosted GitHub Actions runner on PS-PROXY that handles two workflows:

Deploy to PS-PROXY (API)

  1. Push changes to src/** or deploy/** on master branch
  2. GitHub Actions automatically:
    • Backs up production config
    • Stops the service (with force-kill if needed)
    • Builds and publishes to C:\Services\PSI.UniData.API
    • Restores appsettings.Production.json, then re-syncs secret-free config sections (currently Cors) verbatim from deploy/appsettings.Production.json and adds any new top-level sections
    • Restarts the service
    • Runs health check
    • Verifies the CORS allowlist — preflights every production origin against the live service and fails the deploy if any origin is missing its Access-Control-Allow-Origin header

CORS allowlist is config-driven and must not drift. Origins live in Cors:AllowedOrigins in deploy/appsettings.Production.json (source of truth). Because the API uses AllowCredentials(), an origin not in the deployed list receives zero CORS headers and the calling web app fails with No 'Access-Control-Allow-Origin' header. Historically the deploy only added missing config sections and never updated existing ones, so origin changes silently never reached the host (2026-07-14 incident: explorer.progressivesurface.com missing from the live list). The Cors re-sync + post-deploy verification steps above now prevent this. To hot-fix without a full deploy, edit Cors:AllowedOrigins in the live C:\Services\PSI.UniData.API\appsettings.Production.json and restart PSI.UniData.API.

Nightly Data Build (Pipeline)

Runs every night at 1 AM ET (cron 0 6 * * * UTC) — after AFTEC nightly exports finish.

  1. Checks out repo, installs Python 3.12 via actions/setup-python@v5
  2. Extracts LDS Gantt schedules from \\ad.ptihome.com\DFS\LDS\PROJECT
  3. Builds comprehensive dataset from 13 data sources (~60 seconds)
  4. Deploys to \\ad.ptihome.com\DFS\Schedule\SS123\LEADTIME\:
    • comprehensive_dataset.csv — 2,569 projects × 152 columns
    • detail\ subfolder — 6 full-fidelity detail CSVs (all source columns preserved)
  5. Verifies deployment (row count checks, detail file existence)

See Data Brain for full detail on data sources and outputs.

Monitor deployments: https://progressivesurface.ghe.com/ProgressiveSurface/PSI.UniData.API/actions

Manual trigger: Actions → workflow name → “Run workflow”

Credential Resolution

Every UniData session must open as a real AD user whose decrypted password is stored in extensionAttribute2 — UniData has no app-only login path. The API authenticates the caller via Azure AD, then opens the UniData session as one of two AD users depending on which authorization policy the caller matched:

Caller typeAAD token shapeUniData session opens as
Interactive user (web app)User token (upn claim)That user’s own SAM (per-user credential passthrough)
Trusted app on behalf of a userApp-role token with UniData.ActAsUser + X-On-Behalf-Of: <upn> headerThe asserted user’s own SAM — per-user session, no service account
Pipeline / unattendedApp-role token with UniData.ServiceReadUniData:ServiceAccount — a dedicated AD user (apiservice, sAMAccountName API) with UniData creds in extensionAttribute2. Must never be a person’s login: the account runs all service-account/anonymous UniData work, so pointing it at a person impersonates them and consumes their license/audit.

Trusted delegation (UniData.ActAsUser). Added for the MCP server / Ask the Fleet: a trusted application (one signed in via its own app-role token) can act on behalf of a named user by sending X-On-Behalf-Of: <upn>. The API honors the header only for callers holding the UniData.ActAsUser app role (otherwise 403) and opens that user’s own UniData session — so service-ticket reads via /api/service are audited to the real person, never the service account. The MCP app is granted only UniData.ActAsUser (not ServiceRead), so it is structurally incapable of a service-account session.

Consumers of the delegated path today:

CallerIdentityActs asReads
psi-machine MCP / Ask the Fleetits own app registrationthe asking user/api/service, fleet reads
IT Help Desk bot (2026-08)the Function App’s system-assigned managed identitythe HR person who opened the formEMPLOYEE.PUBLIC.1287, EMPLOYEE

The bot’s case is worth noting as the pattern to copy: an Azure resource with no UniData credential of its own reads AFTEC as whichever employee is standing at the form, so the AFTEC audit names Gina or Karen. A managed identity plus the app role plus X-On-Behalf-Of is the whole mechanism — nothing is stored, and dropping the header degrades to 403, not to an anonymous read.

Credentials are cached per UPN for 10 minutes (IMemoryCache) to limit AD query load. AD lookups have a 5-second timeout. The AES secret used to decrypt extensionAttribute2 is read from Azure Key Vault (secret psi-unidata-api--credential-secret) in production via the PS-PROXY managed identity.

Validating a stored credential — the EAAAA sentinel

A correctly encrypted extensionAttribute2 value is Base64 beginning EAAAA — exactly five A’s. The 6th character varies with the random per-encryption IV, so a check written with six A’s silently misclassifies good values. A real blob is also ≥48 characters (4-byte IV-length prefix + 16-byte IV + at least one AES block), which makes length a useful second signal: anything shorter than 48 characters cannot be ciphertext.

The decrypt path treats a non-sentinel value as unencrypted and returns it unchanged. That is deliberate, but it means a writer that omits the encrypt step produces a system where every reader still works — nothing errors and nothing logs. Anything that writes this attribute must therefore assert the sentinel immediately before the write and refuse the write if it fails. Put the guard at the write boundary, since that is the only point every code path funnels through. Periodically auditing the population is also worthwhile: a value not matching ^EAAAA is always a defect.

Note also that PSILocalService.ValidateLogin never returns a directly storable credential — neither of its two branches produces a value in AD format. The client is always responsible for encrypting, with the client secret, before storage. See docs/psi-credential-encryption.md in the PSI.UniData.API repo for the key derivation, both branch behaviours, and the writer’s contract.

Per-user is mandatory for token-bearing requests — no service-account fallback. A request that arrives with a user’s Entra token always opens that user’s own UniData session. The service account is reserved for automation (app-role UniData.ServiceRead) and Development-only anonymous /dev routes. There is deliberately no silent fallback to the service account when a per-user session can’t be opened — that would route a user’s reads/writes through the service identity and mask a per-user auth regression (GHE issue #11). Instead the request hard-fails with a precise status:

Failure (UniDataCredentialException.Reason)HTTP statusMeaning / action
CredentialsNotProvisioned403Caller’s AD account has no extensionAttribute2. Provision their UniData credentials.
UserNotFound403Caller’s Entra UPN didn’t resolve to an on-prem AD user.
SecretNotConfigured503Server-side: the credential-decrypting Key Vault secret isn’t configured (deploy/config issue, not the user’s account).
DirectoryError502AD unreachable / query timed out. Transient.
DecryptionFailed500Stored credential couldn’t be decrypted.

Mapping is centralized in Endpoints/CredentialProblem and applied by both per-endpoint catch blocks and the global exception handler, so every endpoint behaves identically. The UniData username is the on-prem SamAccountName (the user’s three initials), never an AD attribute.

Development vs Production routes

/api/**/dev/* routes are Development-environment only — they are registered only when ASPNETCORE_ENVIRONMENT=Development and use the service account. In the production binary on PS-PROXY they simply do not exist in the route table; a request for /api/project/dev/1234/info returns 404, not 401. This prevents misconfiguration from ever re-opening an anonymous bypass against MRP-PROD.

Pipelines (GitHub Actions, scheduled batches) acquire an app-role token via pipeline/api_auth.py (MSAL confidential client / federated credential) and call the authenticated routes — same URLs humans use. No pipeline-specific route survives in production.

PS-MRPSANDBOX — Development Sandbox Server

PS-MRPSANDBOX.ad.ptihome.com is a dedicated UniData sandbox server used for testing write operations against the API without touching the production AFTEC database.

PropertyValue
HostnamePS-MRPSANDBOX.ad.ptihome.com (also PS-MRPSANDBOX)
Account path/home/pro3 (same as MRP-PROD)
AuthenticationSame AD user credentials as MRP-PROD
PurposeSafe write testing — all CRUD operations, schema changes, data seeding
DataMirror/copy of MRP-PROD data at time of provisioning

To run the API against the sandbox, override UniData:Server in appsettings.Development.json:

{
  "UniData": {
    "Server": "PS-MRPSANDBOX"
  }
}

Then start with ASPNETCORE_ENVIRONMENT=Development dotnet run. The startup log will confirm:

UniData Server: PS-MRPSANDBOX

The sandbox is the required target for any new write endpoint before merging to master. The timesheet controller (/api/timesheets) was fully verified against PS-MRPSANDBOX before production deployment.


Architecture

Authentication Flow (user request)

  1. User signs in to the web app via Azure AD (MSAL.js)
  2. Web app sends the request with a JWT Bearer token
  3. API validates the JWT and extracts the UPN claim
  4. ActiveDirectoryService.GetCredentialsForUser resolves the UPN to a SAM + decrypted UniData password (cached 10 min)
  5. UniDataConnectionFactory.CreateConnection opens the UniData session as that SAM
  6. Query runs with the user’s own AFTEC permissions; audit trail reflects the real user

Authentication Flow (pipeline / app-role)

  1. Pipeline uses pipeline/api_auth.py → acquires app-role token for api://<clientId>/.default
  2. Request hits an authenticated endpoint with the token
  3. API sees the UniData.ServiceRead role claim, routes through GetServiceAccountCredentials
  4. Connection opens as the dedicated service account API (AD account apiservice, sAMAccountName API; configured in UniData:ServiceAccount)
  5. Pipeline traffic is attributed to the dedicated service user, not any human

Authentication Flow (developer / CLI)

Enabled 2026-07-15 (applied by app-registration owner ADevereaux): Microsoft Azure CLI is a pre-authorized client on the API’s app registration (api.preAuthorizedApplications: client 04b07795-8ddb-461a-bbee-02f9e1bf7b46 → delegated scope user_impersonation, scope id e6b2e13c-00a8-41a9-ae24-8df62ff71de3). A developer signed into az obtains a token as themselves with:

az account get-access-token --resource "api://b3db69d9-5d15-457d-b660-88b336fc00fa" --query accessToken -o tsv

The token then follows the normal user flow above — the UniData session opens with that developer’s AFTEC permissions and the audit trail shows the real user. This grants no standing access to anyone who couldn’t already call the API through a PSI SPA; it exists so command-line tooling (e.g. psi-portal/scripts/verify-account-search.mjs --live, which self-acquires via az) can use authenticated endpoints instead of the anonymous /dev routes, which are being phased out. First use: the oracle-§5 acceptance run on the account search (2026-07-15) — deployed endpoint ids + order verified identical to an ACCOUNT.1287 ground-truth pull for CHROMALLOY / CHROMALLOY GAS TURBINE / GENERAL ELECTRIC.

Why Skip PSI Local Service?

PSI Local Service (WCF)U2 Toolkit Direct
~100-500ms overhead per call~1-5ms overhead per call
Single-threaded bottleneckConnection pooling possible
Legacy .NET Framework 4.x.NET Standard 2.0 / .NET 8
Must run on user’s machineRuns on server, shared by all

API Endpoints

Purchase Requests (selected)

EndpointDescription
GET /api/purchase-request/minePRs the caller created
GET /api/purchase-request/awaiting-approvalPRs pending manager approval
GET /api/purchase-request/pc-hardware?months=12PC-hardware sweep (2026-08-13): every PR in the window with a PC.<dept> line — the part code is the paying department. Feeds psi-portal’s PC → department ownership backfill. Gotcha encoded in the source: OPEN.RFQ.LINE has no PART dict (the part code’s dict is literally F2), and selection uses LIKE "PC..." — the = "PC.]" truncation dialect silently matches nothing via VB_GETLIST.

Health

EndpointDescription
GET /api/healthBasic health check
GET /api/health/unidataUniData connection test

BOM (Bill of Materials)

Recursive BOM explosion using the VB_BOMX.REV1 UniData subroutine. Returns the full multi-level Bill of Materials with inventory, lead times, and MRP classification. Safety limits prevent runaway explosions: max depth of 20 levels, max 10,000 items, and 30-second per-call timeout.

EndpointDescriptionAuth
GET /api/bom/job/{jobNumber}BOM explosion for a jobRequired (user or app-role)
GET /api/bom/part/{partNumber}BOM explosion for a partRequired (user or app-role)
POST /api/bom/jobsBatch — explode BOMs for up to 50 jobs in one callRequired (user or app-role)
GET /api/bom/dev/job/{jobNumber}Service-account pathAnonymous (Development env only)
GET /api/bom/dev/part/{partNumber}Service-account pathAnonymous (Development env only)
POST /api/bom/dev/jobsService-account batchAnonymous (Development env only)

Batch BOM (POST /api/bom/jobs): Body is a JSON array of job number strings. Opens one UniData session and reuses it for all explosions. Returns { results: { "jobNo": BomResponse, ... }, found, empty, failed, emptyIds, failedIds, elapsedMs, totalDatabaseCalls }. Used by sync_bom_data.py and rockwell_lifecycle.py in the pipeline — both now acquire an app-role token via pipeline/api_auth.py and call this authed endpoint directly. Reduces ~2,600 sequential HTTP calls to ~52 batch calls.

Response-level fields:

FieldTypeDescription
rootIdentifierstringJob or part number that was exploded
totalItemsintTotal items in BOM
maxLevelintDeepest hierarchy level
elapsedMslongTime to retrieve the BOM
databaseCallsintNumber of subroutine calls made
wasTruncatedboolWhether BOM hit depth or item count limit
truncationReasonstring?"max_depth" or "max_items" if truncated
warningsstring[]Errors from failed subroutine calls (partial data indicator)

Item-level fields:

FieldTypeDescription
partNumberstringPart number
descriptionstringPart description
levelintBOM depth (0 = root)
wbsNumberstringWork Breakdown Structure position
parentPartNumberstring?Parent part (null for root)
mrpCodestringMRP type from PRODUCT table (P=Purchased, M=Manufactured, D=Document)
gtCodestringGT Code (BOM hierarchy classification)
isPurchasedboolmrpCode == "P" or gtCode == "PU"/"VM"
isManufacturedboolmrpCode == "M"
qtyPerParentdecimalQuantity per parent assembly
qtyPerLegdecimalQuantity per leg
unitOfMeasurestringUnit of measure (EA, FT, LB, etc.)
mfgLeadTimeintManufacturing lead time (days)
purchaseLeadTimeintPurchase lead time (days)
cumulativeLeadTimeintCumulative lead time (days)
onHanddecimalOn-hand inventory
onOrderdecimalOn order quantity
availabledecimalAvailable quantity
totalAllocateddecimalTotal allocated
hasChildrenboolWhether this item has child components
isTransientboolTemporary assembly flag
drawingNumberstringParent drawing number

Response Example:

{
  "rootIdentifier": "2242",
  "totalItems": 2066,
  "maxLevel": 5,
  "elapsedMs": 471,
  "databaseCalls": 528,
  "wasTruncated": false,
  "truncationReason": null,
  "warnings": [],
  "items": [
    {
      "level": 1,
      "wbsNumber": "00101",
      "partNumber": "281574",
      "description": "Front Wall Per Print",
      "mrpCode": "P",
      "gtCode": "",
      "isPurchased": true,
      "isManufactured": false,
      "qtyPerParent": 1,
      "unitOfMeasure": "EA",
      "purchaseLeadTime": 15,
      "cumulativeLeadTime": 15,
      "hasChildren": false,
      "onHand": 0,
      "available": 0
    }
  ]
}

Projects (Machine Provenance)

Project/machine metadata from PROJECT.1287, with resolved machine-type, customer, and employee names. Each endpoint has a dev/ anonymous (service-account) twin for SPA development.

EndpointDescriptionAuth
GET /api/project/{job}/infoEnriched project metadata (customer, machine type, team, dates)Required
GET /api/project/{job}/siblingsProjects with the same machine typeRequired
GET /api/project/{job}/lineageReference-chain predecessors + successors (REF.PROJ.NO / shared serial)Required
GET /api/project/machine-typesAll machine types with project countsRequired
GET /api/project/by-type/{typeCode}All projects for a machine typeRequired
GET /api/project/search?q={query}Search projects by description keyword (full scan)Required
GET /api/project/by-serial/{serial}Look up project(s) by machine serial number (indexed WITH SERIAL.NO BSELECT)Required
GET /api/project/dev/by-serial/{serial}Dev endpointAnonymous

Serial lookup (GET /api/project/by-serial/{serial}): resolves a machine serial to its project/job number — used by psi-service (Customer Service Manager)‘s Serial # filter to auto-fill the Project # field. Returns { serial, totalResults, projects: [ { projectNumber, serialNumber, description, customerName, status, shipDate, … } ] }. No match → 200 with an empty projects array (not 404). A serial usually maps to one project, but a list is returned since retrofits can share/reference a serial.

Accounts (ACCOUNT.1287)

Account search replicating the legacy VB6 Account Manager list contract (the vb6-specs acceptance oracle extracted from frmAccount.frm::LoadAccounts). Added 2026-07-15 as the live data source for the psi-portal Customer Data Reconciliation account rail.

EndpointDescriptionAuth
GET /api/accounts/search?name={q}Search accounts by nameRequired

Contract: base filter DELETED # 'Y' (always), name match UC.NAME LIKE '...q...' (UniQuery “contains”), ordered BY UC.NAME — character-code sort of the uppercased name, location then key as tiebreaks. Returns { query, count, accounts: [{ accountNo, name, location }], elapsedMs } where accountNo is the full CO!ACCT record key (e.g. 1!0409).

Implementation notes: UC.NAME/UC.LOCATION are dictionary I-descriptors, evaluated via LIST … TOXML (DataQueryService.ListWithCriteriaToXml) — no attribute numbers hardcoded. The search text is uppercased and whitelisted (letters/digits/space/.,&/-, and at least 2 letters/digits so punctuation-only input can’t devolve into a match-everything wildcard) before entering the quoted literal; AccountSearchSanitizerTests covers the gate. No anonymous /dev twin exists in production (it is mapped only in the Development environment) — this endpoint set follows the newer authenticated-only posture.

Acceptance harness: psi-portal/scripts/verify-account-search.mjs pulls ACCOUNT.1287 ground truth, simulates the expected result for a query, and with --live diffs this endpoint’s response (ids + order) against it.

Parts

EndpointDescriptionAuth
GET /api/parts/{partNumber}Part detailsRequired
GET /api/parts/search?q={query}Search partsRequired
GET /api/parts/dev/{partNumber}Dev endpointAnonymous
GET /api/parts/dev/search?q={query}Dev endpointAnonymous
POST /api/parts/dev/batchBatch — look up up to 500 parts in one callAnonymous

Batch Parts (POST /api/parts/dev/batch): Body is a JSON array of part number strings. Returns { parts: { "partNo": PartInfo, ... }, found, notFound, notFoundIds, elapsedMs }. Uses VB_PARTINFO.REV2 with session reuse.

Work Orders

EndpointDescriptionAuth
GET /api/wo/{workOrderNumber}Work order detailsRequired
GET /api/wo/dev/{workOrderNumber}Dev endpointAnonymous

IBM (Issued But Missing)

EndpointDescriptionAuth
GET /api/ibm/dev/activeIBM items on active (Released) WOsAnonymous
GET /api/ibm/dev/allAll IBM items (active + completed)Anonymous
GET /api/ibm/dev/job/{jobNumber}IBM items for a specific jobAnonymous
GET /api/ibm/dev/summaryCounts by WO status, grouped by jobAnonymous

Each IBM item includes lineNote (WIPBMF field 27) and notes (from WIPBMF.NOTES table) — the same notes created by the WIPBOM desktop app. See Inventory & Work Orders: IBM for field-level documentation.

Routing / WIP State (2026-08-10, GHE #115)

Read-only routing state that exists in no AFTEC export — built for WIP/routing dashboards (see the psi-analytics wip/ study). App-role (UniData.ServiceRead) callers are supported so nightly pipelines can refresh these unattended; /dev twins are Development-only.

EndpointDescriptionAuth
GET /api/routing/notroutedLive NOTROUTED.1287 queue: per-part description, MW/EA/MA/JS route suggestions (PRODUCT.1287 F9), standard-route existence, job priority/seq/levelRequired (app-role OK)
POST /api/routing/wiproute-lines/batchWIPROUTE.LINE detail for up to 200 WOs: routed hours (SETUP-HRS/MAN-HRS), completion flag, plan/actual dates, LAST.LAB.DATE (F38). includeComplete=false → open ops onlyRequired (app-role OK)
GET /api/routing/promise-drift?job=Released WOs (OPENWO PC=“R”) where ORIG.PROM ≠ DUEDATE, with drift daysRequired (app-role OK)
GET /api/routing/wo/{wo}/laborPer-entry labor history for one WO (LABORHIST via indexed WO.NO, ~90ms): work date, employee, operation, setup/labor hours, rework flag, overtime — plus per-operation rollupRequired (app-role OK)

Why these exist: wiplabor.csv only exports lines with posted labor (never-started ops have no hours anywhere), wiprouteline.csv is filtered vs the live table, NOTROUTED.1287 and ORIG.PROM are unexported, and the generic /api/query route is per-user-only. Status semantics: OPENWO F18 (PC) is the WO open/closed discriminator (C complete / R released / blank unreleased).

?sandbox= — read PS-MRPSANDBOX from an authenticated route (2026-08-12, GHE #119). All four routing endpoints accept an optional sandbox boolean. Omitted or false reads live MRP-PROD; true reads PS-MRPSANDBOX. This exists because psi-dispatch renders a SANDBOX-banner’d grid from the sandbox and its drill-downs must read the same source the grid came from — otherwise a labor history would silently mix sandbox routing with production hours. Verified live on 2026-08-13: promise-drift?job=2424 returns 110 parts against prod and 49 against PS-MRPSANDBOX. Note the parameter is a nullable bool? — an earlier revision made it required, so a paramless call 400’d; see the same class of bug still open for the vendor AP-history endpoints in GHE #117.

Dictionary/Schema

Live UniData DICT introspection — table field maps with attribute positions, conversion codes, formats, multivalue flags, and I-type (calculated) expressions. Base route is /api/dict (not /api/dictionary). Generic table data reads live under /api/data/* (see Data below), not /api/query.

EndpointDescriptionAuth
GET /api/dict/tablesList all UniData tablesRequired
GET /api/dict/tables/{tableName}Dictionary definition (fields) for a tableRequired
GET /api/dict/fields/search?q={query}Search dictionary entries by field nameRequired
GET /api/dict/dev/tablesDev twinAnonymous
GET /api/dict/dev/tables/{tableName}Dev twinAnonymous
GET /api/dict/dev/fields/search?q={query}Dev twinAnonymous
GET /api/dict/dev/tables/{tableName}/diagnoseTest which dictionary access methods work for a tableAnonymous

Response fields (per dictionary entry): fieldName, attributePosition, dictionaryType (D = physical attribute, I = calculated), displayName, conversionCode, format, isMultivalue, isSubvalue, associatedField, iTypeExpression, dataType. A table’s fieldCount splits into dataFieldCount (D-type, incl. aliases) + calculatedFieldCount (I-type). See PRODUCT.1287 Field & Program Map for a worked example of how I-type entries encode virtual foreign keys via TRANS().

DICT reads are live and can be slow (~1.2 s for a 61-field table) because the dictionary is read from UniData on each call. A caching layer exists (see Dictionary Cache admin below) but is currently warmed only for the write-path tables. Bulk I-type evaluation (/api/data/dev/{table}/export-fields, which runs LIST … TOXML) is heavy and can time out on wide/large tables — prefer raw export + client-side conversion, or a warmed cache, for those.

Dictionary Cache (admin)

Server-side cache of parsed dictionary schemas, backed by on-disk JSON under data/dictionaries/. Anonymous — internal admin surface, consistent with the other /api/admin/* groups.

EndpointDescription
GET /api/admin/dict-cache/statusCache status (which tables are cached)
POST /api/admin/dict-cache/refresh/{tableName}Refresh cache for one table
POST /api/admin/dict-cache/refresh-allRefresh all tables (long-running)
GET /api/admin/dict-cache/{tableName}View cached schema for a table
DELETE /api/admin/dict-cache/{tableName}Evict a table from cache

Redbook (RFC Management)

Full CRUD operations for quality issue tracking. See Redbook Web for the frontend application.

RFC Operations

EndpointDescriptionAuth
GET /api/redbook/rfc/{rfcNo}Get single RFCRequired
POST /api/redbook/rfcCreate new RFCRequired
PUT /api/redbook/rfc/{rfcNo}Update RFCRequired
DELETE /api/redbook/rfc/{rfcNo}Delete RFCRequired
POST /api/redbook/searchSearch RFCsRequired
GET /api/redbook/by-project/{projectNo}Get RFCs for projectRequired

Lookup Endpoints (Dev)

EndpointDescriptionData Source
GET /api/redbook/dev/lookups/allAll lookups combinedMultiple
GET /api/redbook/dev/lookups/employeesActive employeesEMPLOYEE.PUBLIC.1287
GET /api/redbook/dev/lookups/accountsJob Shop accountsACCOUNT.1287
GET /api/redbook/dev/lookups/cost-centersCost centersCOCE
GET /api/redbook/dev/lookups/audit-typesAudit typesStatic
GET /api/redbook/dev/lookups/prioritiesPriority levelsStatic
GET /api/redbook/dev/lookups/departmentsDepartment codesStatic
GET /api/redbook/dev/lookups/problem-typesProblem typesStatic
GET /api/redbook/dev/lookups/root-causesRoot causesStatic
GET /api/redbook/dev/lookups/statusesRFC statusesStatic

Search Request Example:

{
  "projectNo": "95188",
  "status": "Open",
  "department": "ENG",
  "startDate": "2024-01-01",
  "endDate": "2024-12-31",
  "searchText": "design error",
  "skip": 0,
  "take": 50
}

RFC Response Example:

{
  "rfcNo": "12345",
  "projectNo": "95188",
  "status": "Open",
  "priority": "2",
  "problemDescription": "Drawing missing dimensions...",
  "enteredBy": "AMD",
  "enteredDate": "2024-01-15",
  "departmentStatuses": [
    { "department": "ENG", "status": "Complete" },
    { "department": "MFG", "status": "Pending" }
  ],
  "engChanges": [
    { "ecnNo": "ECN-2024-001", "description": "Update dimensions" }
  ]
}

Sales Orders & Quotes

Spare parts quote and sales order lifecycle endpoints. Used by the MCP server’s get_open_quotes, get_quote_detail, get_sales_orders, and get_sales_history tools.

EndpointDescriptionAuth
GET /api/sales-order/dev/quotes?customer={}&contact={}&email={}Open quotes by customer, contact, or emailAnonymous
GET /api/sales-order/dev/quotes/{quoteNo}Quote detail with line items and linked SOsAnonymous
GET /api/sales-order/dev/orders?customer={}Open sales orders (VB_OPENORDLIST)Anonymous
GET /api/sales-order/dev/history?customer={}&begin={}&end={}&part={}Invoiced sales history (VB_SODET.REV4)Anonymous

Data Sources: OPEN.QUOTE.HEAD.1287 (31K+ quotes), OPEN.QUOTE.LINE.1287 (line items), CONTACT.1287 (email lookup), OPEN.ORD.HEAD.1287 (SO linkage), VB_OPENORDLIST, VB_SODET.REV4.

Obsolete Parts

Discover obsolete parts by search criteria or within project BOMs. Returns replacement part hints (parsed from secondary description), manufacturer details, and optionally cross-references. Designed for CS reps to quickly identify obsolete parts and their replacements.

EndpointDescriptionAuth
GET /api/obsolete-parts/dev/searchSearch obsolete parts with filters (description, manufacturer, designCategory, gtCode, xref). Paginated.Anonymous
GET /api/obsolete-parts/dev/by-project/{jobNumber}Find obsolete parts in a project’s BOM with BOM location contextAnonymous
POST /api/obsolete-parts/dev/by-projectsFind obsolete parts across multiple projects (max 20), deduplicatedAnonymous
GET /api/obsolete-parts/dev/{partNumber}/chainAI-powered replacement chain — follows replacements until active part foundAnonymous
POST /api/obsolete-parts/dev/warm-cachePre-parse PRODUCT.NOTES for a batch of parts to warm the AI cacheAnonymous

Search filters: description, manufacturer, designCategory, gtCode, xref, includeInactive, includeXrefs, page, pageSize

Key response fields: replacementPartHint (parsed from secondary description — e.g., “RPL AD3616901 REV 2” → “AD3616901 REV 2”), obsolescenceStatus (“O”=Obsolete, “I”=Inactive), manufacturer code/name, design category, GT code, cost, on-hand inventory.

Replacement Chain (/chain): Reads PRODUCT.NOTES free-text notes, uses Azure OpenAI (GPT 5.2) to extract replacement part numbers with context and confidence, then recursively follows the chain until an active part is found. Results are cached (7-day TTL, disk-persisted). First call ~5s (AI), subsequent calls ~200ms (cache). Example: part 054761 (Obsolete) → 054524 (Active, “for parts list”) + 053193 (Active, “for Installation & Operation”).

Data Sources: PRODUCT (INC/OBS flag at attr 8, MANF_CLASS at attr 20), VB_PARTINFO.REV2 (descriptions), ITEMMANF (manufacturer code, design category), MFG.1287 (manufacturer name), PRODXREF/REFXPROD (cross-references), PRODUCT.NOTES (free-text notes for AI parsing), BomService (project BOM explosion), Azure OpenAI (GPT 5.2 for notes parsing).

Spare Parts

Discover spare parts (consumables, recommended spares, general replacements) within a project’s BOM by reading BMF.1287 manual inclusion flags. Each spare part is enriched with MTBF (mean time between failures), obsolescence status, manufacturer details, and a suggested price (2.5x markup on last cost).

EndpointDescriptionAuth
GET /api/spare-parts/dev/by-project/{jobNumber}Find all spare parts in a project’s BOM, categorizedAnonymous
GET /api/spare-parts/dev/by-project/{jobNumber}/csvExport spare parts as CSV (opens in Excel)Anonymous

Categories: C = Consumable, R = Recommended Spare, G = General Replacement (from BMF.1287 attr 2).

Key response fields: categoryCode/categoryName (C/R/G), recommendedQuantity (from BMF.1287 attr 3), mtbf (hours, from PRODUCT.1287 attr 8), obsolescenceStatus (“O”/“I”/""), manufacturerCode/manufacturerName, designCategory, lastCost, suggestedPrice (lastCost x 2.5), parentAssembly/parentDescription.

Data Sources: BMF.1287 (manual inclusion flag at attr 1, category at attr 2, recommended qty at attr 3), PRODUCT.1287 (MTBF at attr 8), PRODUCT (INC/OBS flag at attr 8), VB_PARTINFO.REV2 (descriptions, GT code, cost, on-hand), ITEMMANF (manufacturer code, design category), MFG.1287 (manufacturer name), BomService (project BOM explosion).

Timesheets

Full CRUD for the AFTEC timesheet lifecycle (week header + detail lines + optional machine time), plus read helpers for the web tester: timesheet list, posted history, dropdown reference data, and employee name search. Designed to replace the WPF timesheet app for employees who log in with individual accounts.

Auth model (dispatch /dev parity): every endpoint — production and /dev — is Azure AD authenticated and runs as the calling user in UniData via per-user credential passthrough. The only difference is the target server: the plain routes hit production AFTEC (MRP-PROD); the /dev twins hit the SANDBOX server (PS-MRPSANDBOX) via CreateSandboxConnection(upn). There are no anonymous timesheet endpoints. (This replaced the earlier anonymous service-account /dev model.)

Endpoint (+ /dev twin)Description
GET /api/timesheets/{empNo}/{weekDate}Load timesheet header + lines + machine time for one employee/week (unposted TS.HEAD.1287)
POST /api/timesheetsSave timesheet (header + all lines; replaces previous line set)
DELETE /api/timesheets/{empNo}/{weekDate}Delete timesheet header and all lines
GET /api/timesheets/list/{empNo}List an employee’s unposted timesheets (direct-read window over TS.HEAD.1287)
GET /api/timesheets/history/{empNo}?start=&end=Posted labor history over a date range via VB_TIMESHEETINQ.REV3 (posted days are not in TS.HEAD.1287)
GET /api/timesheets/refdataDropdown feeds: cost centers (COCE), work centers (WOCE), operations (OPERATION), misc types (TS.MISC.1287), and the CWO.XREF.1287 cost-center→work-center→operation cascade
GET /api/timesheets/employees?q=Search employees by name/number over EMPLOYEE.PUBLIC.1287 (min 2 chars, 400 on blank, capped 100)

The web tester (PSI Portal → Tools → Timesheet, /tools/timesheet) drives all of these through the portal’s own MSAL instance and defaults its target to Sandbox. Its “Find my timesheet” button resolves the signed-in user’s AFTEC employee number from the Entra employeeId (Graph /me).

weekDate format: ISO 8601 date string yyyy-MM-dd (e.g. 2026-04-28). Invalid dates return HTTP 400.

Lock conflict: If another session holds the UPD.LOCK (F10) field on the TS.HEAD.1287 record, POST returns HTTP 409 with { errorMessage: "Record is locked by <value>" }. The API does not acquire locks — it checks on write and fails fast. This covers the migration window while the WPF app may still coexist.

Save request body (POST /api/timesheets):

{
  "header": {
    "empNo": "0042",
    "weekDate": "2026-04-28",
    "regularHours": 40.0,
    "sickHours": 0,
    "vacationHours": 0,
    "holidayHours": 0,
    "offDutyHours": 0,
    "miscHours": 0,
    "miscType": "",
    "miscNote": "",
    "offDutyType": "",
    "offDutyNote": "",
    "employeeType": "D",
    "costCenter": "125",
    "vacationApproval": "",
    "lines": [
      {
        "lineNumber": 1,
        "costCenter": "125",
        "operation": "010",
        "workOrderCenter": "",
        "workOrderNumber": "4900",
        "generalLedgerNumber": "",
        "drawingNumber": "",
        "laborHours": 8.0,
        "setUpHours": 0,
        "partialComplete": "",
        "quantityCompleted": 0,
        "routeLine": "",
        "machineTime": null
      }
    ]
  },
  "oldLineCount": 0,
  "deletedMachTimeId": null
}

oldLineCount — number of lines from the previous save. The service deletes lines 1..N before writing the new set. Pass 0 for a first-time save.

deletedMachTimeIdMACH.TIME.1287 ID (without 1! prefix) to delete before saving. Null if no machine time record is being removed.

Machine time: When a line contains a machineTime object, the service auto-assigns a new MACH.TIME.1287 ID, writes the record, and overwrites that line’s laborHours/setUpHours with machineTime.adjLabor/machineTime.adjSetup. The bare ID (without 1! prefix) is stored in TS.1287 F12. IDs are assigned by scanning the table for the current max on first use (process startup) and incrementing in-memory thereafter, protected by a SemaphoreSlim — verified correct under concurrent writes.

Dept 110 gate: drawingNumber is only written to TS.1287 F6 when header.costCenter == "110". It is blanked for all other departments regardless of what the client sends.

Tables accessed:

TableKey FormatOperation
TS.HEAD.12871!{EMPNO_4pad}!{CONVDATE}Read (lock check), Write (save), Delete
TS.12871!{EMPNO}!{CONVDATE}!{LINE:D3}Delete (old lines), Write (new lines)
MACH.TIME.12871!{AUTO_ID}Write (new machine time records); also scanned once at startup for max ID
EMPLOYEE.PUBLIC.12871!{EMPNO_4pad}Field 6 cleared on each head save (fire-and-forget)

Employee record keys — two files, two formats (verified live 2026-08-06). Both take the employee number zero-padded to four, and they differ in whether a ! separates it from the company code:

FileKeyExample (emp 793 / 4200)
EMPLOYEE.PUBLIC.1287{CO}!{EMPNO_4pad}1!0793, 1!4200
EMPLOYEE (payroll){CO}{EMPNO_4pad}no delimiter10793, 14200

1!793 is a 404, not an alternate spelling. The concatenated payroll form follows that file’s own dictionary expression (TRANS(EMPLOYEE,CO:EMPNO,…)); it is where START.DATE and hourly/salary STATUS live, while department, shift, USERID, RPT.TO and SUBCONTRACT are on the public record. TITLE exists on the public record but is blank on every record checked — job titles are not usable from AFTEC.


Data (Generic Table Access)

EndpointDescriptionAuth
GET /api/data/dev/{tableName}?limit={10}Sample records from any tableAnonymous
GET /api/data/dev/{tableName}/{recordId}Single record by IDAnonymous
GET /api/data/dev/{tableName}/export?page={1}&pageSize={1000}Paginated recordsAnonymous
GET /api/data/dev/{tableName}/export-fields?fields={csv}&page={1}&pageSize={1000}Records with I-type field evaluation (LIST TOXML)Anonymous
POST /api/data/dev/{tableName}/batchBatch — read up to 5,000 records by IDAnonymous

Batch Read (POST /api/data/dev/{tableName}/batch): Body is a JSON array of record ID strings. Opens one UniData session, opens the file handle once, and reads all records in a tight loop. Returns { records: { "id": DataRecord, ... }, notFoundIds, found, notFound, elapsedMs }. This is the foundational batch pattern — 900x faster than sequential reads on large workloads (proven in the notes-based obsolescence scan).

Query / Aggregation (Generic Read-Only)

Run a single read-only RetrieVe/UniQuery command server-side and get native XML (TOXML) or parsed JSON in one call — distinct-value lists, BREAK.ON/TOTAL group-by counts, and column exports without paging the whole file over REST. Backed by the cataloged VB_SYSTEMSTAT.REV1 (EXECUTE … CAPTURING).

EndpointDescriptionAuth / Target
POST /api/queryRun a read-only command (LIST/SELECT/SSELECT/SORT/COUNT)Per-user · PROD
POST /api/query/devSame, against the sandboxPer-user · SANDBOX (PS-MRPSANDBOX)
GET /api/query/{table}/distinct?field={F}Distinct values of a fieldPer-user · PROD
GET /api/query/{table}/aggregate?breakOn={F}Group-by counts (BREAK.ON … TOTAL COUNT)Per-user · PROD
(each convenience route has a /api/query/dev/… sandbox twin)Per-user · SANDBOX

Auth model (identical to Dispatch): every route is authenticated per-user (Entra→UniData) — not anonymous. /dev runs the same per-user session against the sandbox server so a UI pilot can exercise it without touching production AFTEC. There is no service-account / X-On-Behalf-Of form.

Request: { "command": "LIST ITEMMANF BREAK.ON F9 TOTAL EVAL \"COUNT(F9)\" TOXML", "format": "json" }formatxml (native TOXML capture) | json (parsed rows) | raw. TOXML is auto-appended for LIST/SORT when format is xml/json.

Guardrails: read-only verb allow-list (the primary guard — writes/admin verbs like DELETE/CLEARFILE/ED/RUN are rejected), a destructive-keyword deny-list, ;/control-char rejection, table-name validation + optional Query:AllowedTables allow-list, server-side timeout + row/char caps, and per-caller audit of every command. Drove issue #55: a “distinct + counts for GT.DESIGN/GT.MANF/GT.CODE” export that took ~85 /export pages (~8 min) is now one request.

Optional upgrade — VB_WEBQUERY: a custom UniBasic sub (source in unidata/VB_WEBQUERY, runbook in docs/VB_WEBQUERY-DEPLOY.md) adds the SELECT … SAVING UNIQUE distinct-key path and a server-side row cap. It ships as source only; catalog it on UniData and set Query:Backend = VB_WEBQUERY to activate.

Manufacturers

EndpointDescriptionAuth
GET /api/manufacturer/dev/by-part/{partNumber}OEM manufacturer for a partAnonymous
POST /api/manufacturer/dev/by-partsBatch — manufacturers for up to 200 partsAnonymous
GET /api/manufacturer/dev/by-code/{mfgCode}Manufacturer details by codeAnonymous
GET /api/manufacturer/dev/list?page={1}&pageSize={100}All 2,400+ manufacturer codes (paginated)Anonymous

Data Sources: ITEMMANF (part→mfg code, design category), MFG.1287 (mfg code→name/address), PVXREF (part→primary vendor), PRODUCT (INC/OBS flag, manufacturer class).

Product Classes (Product Families)

The product-family registry and membership, so consumers (e.g. psi-docgen) can group content by product class (“Air Dryer”, “Acoustical Enclosure”, …) the way the .NET Document Manager tree does. Replaces the legacy WCF path (remote subs VB_PRODUCTCLASSLIST.REV1 / VB_PRODUCTLIST.REV1) with direct UniData reads. Issue #73.

EndpointDescriptionAuth
GET /api/product-classesRegistry of product classes: { id (4-char code), name }Required
GET /api/product-classes/{id}/productsCurated LDS products in a class: { productNo, physNo, incObs, inactive }Required
GET /api/products/ldsFull product↔class membership in one bulk fetch: { productNo, physNo, classId, className, incObs, inactive }Required
GET /api/product-classes/dev · /dev/{id}/products · GET /api/products/dev/ldsAnonymous dev variants (service account)Anonymous

Response envelopes: { totalClasses, classes: [...] }, { classId, className, totalProducts, products: [...] }, { totalProducts, products: [...] }. Inactive/obsolete rows are included — callers filter (incObs: ""=active, "I"=inactive, "O"=obsolete; inactive is a convenience bool). Example: class 0011 = AIR DRYER, with 39 products including the 247344/247345 product/physical pair.

Data Sources: PRODUCT.CLASS.1287 (id = key segment 2, name = attr 1 CLASS.NAME), PRODUCT.LDS.1287 (PHYS.NO attr 2, PROD.CLASS attr 4, I-type INACT.OBS = TRANS(PRODUCT,@ID,'INC/OBS')). Reads go through DataQueryService’s LIST … TOXML helpers so dictionary I-type fields are evaluated by UniData; the bulk read chunks LIST … WITH @ID by 100 in one session.

Service Tickets (SERVICE.1287)

Read-only service-ticket endpoints over SERVICE.1287, consumed by the Customer Service Manager web app (psi-service) — the web replacement for the legacy VB6 PTIServiceTicket. Phase 1 is read-only; create/edit, the contact editor, and the activity-log grid are tracked as later phases (GHE issues).

EndpointDescriptionAuth
GET /api/serviceList service tickets (filterable)Required
GET /api/service/searchSearch ticketsRequired
GET /api/service/{ticketNumber}Full VB6-parity ticket detail (attribute map + lookups + phone)Required
GET /api/service/dev/…Dev twins of the three aboveAnonymous (Development only)

Trusted delegation: the authenticated routes open the caller’s own per-user Entra→UniData session (per-user AFTEC audit). A trusted app holding the UniData.ActAsUser app role may act on behalf of a named user via the X-On-Behalf-Of: <upn> header (used by the MCP server / Ask-the-Fleet) — there is deliberately no service-account fallback on this route, so a service-ticket read is always attributed to a real person. Opened-by / assigned employee numbers are resolved to names.

Contacts (CONTACT.1287)

Full CRUD for contacts. /api/contacts/* is authenticated (per-user session); /api/contacts/dev/* is anonymous for local SPA development.

EndpointDescriptionAuth
GET /api/contacts/account/{accountNo}Contacts for an accountRequired
GET /api/contacts/{contactNo}Single contactRequired
POST /api/contactsCreate contactRequired
PUT /api/contacts/{contactNo}Update contactRequired
DELETE /api/contacts/{contactNo}Delete contactRequired
…/api/contacts/dev/…Anonymous twins of all fiveAnonymous

Dispatch (WORK.WIPLINE.1287)

Dispatch-list read + shop-floor status writes over WORK.WIPLINE.1287, consumed by the psi-dispatch BFF (UniDataDispatchProvider). All routes use the caller’s per-user session. /{workCenter} reads production (UniData:Server); /dev/{workCenter} reads the sandbox (PS-MRPSANDBOX) so the pilot runs against sandbox data safely.

EndpointDescriptionAuth
GET /api/dispatch/{workCenter}Dispatch list for a work center (production)Required
GET /api/dispatch/dev/{workCenter}Dispatch list (sandbox)Required
POST /api/dispatch/operator-statusSet operator status on a lineRequired
POST /api/dispatch/notesSave a line noteRequired
POST /api/dispatch/dev/operator-status, POST /api/dispatch/dev/notesSandbox write twinsRequired

Inventory (ITEMQTY / INVHIST)

Current inventory status, transaction history, and 24-month trends. Data sources: ITEMQTY, INVHIST (linked list via INVXREF), PRODUCT, VB_GET.ITEMHIST, VB_INVINQ.2.

EndpointDescriptionAuth
GET /api/inventory/{partNumber}/statusCurrent inventory status for a partRequired
GET /api/inventory/{partNumber}/transactionsTransaction history for a partRequired
GET /api/inventory/{partNumber}/monthly24-month inventory trendRequired
GET /api/inventory/dev/{partNumber}/…Dev twins of all threeAnonymous (Development only)

Vendors & Purchasing

Vendor, open-PO, and AP-history lookups.

EndpointDescriptionAuth
GET /api/vendor/by-part/{partNumber}Vendors for a partRequired
GET /api/vendor/by-po/{poNumber}Open PO by PO numberRequired
GET /api/vendor/by-vendor/{vendorNumber}Open POs for a vendorRequired
GET /api/vendor/open-posAll open purchased POsRequired
GET /api/vendor/{vendorNumber}/historyVendor AP historyRequired
GET /api/vendor/{vendorNumber}/history/by-po/{poNumber}AP history for a specific PORequired
GET /api/vendor/dev/…Dev twins of all sixAnonymous (Development only)

Floor Stock & Cost Analysis

Work-order classification, cost attribution, and floor-stock WO discovery.

EndpointDescriptionAuth
GET /api/floorstock/{partNumber}/cost-breakdownCost breakdown for a partRequired
GET /api/floorstock/{partNumber}/classified-transactionsClassified transactions for a partRequired
GET /api/floorstock/floor-stock-wosDiscover floor-stock work ordersRequired
GET /api/floorstock/{job}/cost-attributionJob cost attributionRequired
GET /api/floorstock/dev/…Dev twins of all fourAnonymous (Development only)

Hours

EndpointDescriptionAuth
GET /api/hours/burn/{jobNumber}Monthly labor burn for a projectRequired
GET /api/hours/dev/burn/{jobNumber}Dev twinAnonymous (Development only)

Lead Time

Serves the nightly comprehensive_dataset.csv (2,569 projects × 138 cols) built by the pipeline (see Data Brain) to the PSI Explorer Lead Time / Timeline UI.

EndpointDescriptionAuth
GET /api/leadtime/… (dev twins available)Comprehensive lead-time analysis datasetRequired / Anonymous dev
GET /api/leadtime/dev/scorecardDepartment scorecard (year × dept metrics)Anonymous
GET /api/leadtime/dev/clustersPer-project, per-dept work-cluster detailAnonymous
GET /api/leadtime/dev/engineersEngineer behavior profiles vs project outcomesAnonymous
GET /api/leadtime/dev/normsBaseline-era norm profiles for cluster metricsAnonymous
GET /api/leadtime/customer-segments (dev twin available)Customer segmentation — GovSegment, EndMarket, Channel, ValueTier, Recency, GeoRegion + history (one row per customer)Required / Anonymous dev

Schedule

Department schedules and floor-space/bay assignments, sourced from Excel files + LDS Gantt.

EndpointDescriptionAuth
GET /api/schedule/dev/departmentsAll department schedulesAnonymous
GET /api/schedule/dev/department/{department}Schedule for one departmentAnonymous
GET /api/schedule/dev/floor-spaceFloor space & bay assignmentsAnonymous
GET /api/schedule/dev/projectsCondensed schedule context for all projectsAnonymous
GET /api/schedule/dev/lds/{jobNumber}LDS Gantt data for a projectAnonymous

Subroutine Registry (admin)

Catalog of the AFTEC VB_* subroutine surface plus a generic executor. POST /call can run a registered subroutine, or an unregistered one when argCount is supplied to bypass the registry.

EndpointDescription
GET /api/admin/subroutine/registryList all registered subroutines
GET /api/admin/subroutine/registry/statusRegistry stats by domain / pattern / status
GET /api/admin/subroutine/registry/searchSearch subroutines
GET /api/admin/subroutine/registry/domains / …/domain/{domain}Domains and their subroutines
GET /api/admin/subroutine/registry/{name}Full metadata for one subroutine
POST /api/admin/subroutine/callExecute a subroutine (registered or unregistered)

Activity Dashboard (admin)

Live “who is using the API and via which app” view. The HTML page (/admin/activity) signs the viewer in with MSAL.js and calls the JWT-protected JSON snapshot. Data comes from ActivityTrackerService (live in-process counters + startup-log backfill). Any authenticated PSI user may view (group gating is a later phase).

EndpointDescriptionAuth
GET /api/admin/activityJSON snapshot of current callersRequired
GET /api/admin/activity/configDashboard client config (MSAL)Anonymous
GET /admin/activityHTML dashboard pageAnonymous (page shell; data call is authed)

System Status (admin)

Self-hosted recreation of the legacy VB6 ReadDict “System Status” screen: live UniData session list and concurrent-license utilization. Read-only in v1 (no session kill). The HTML page (/admin/system) signs the viewer in with MSAL.js and calls the JWT-protected JSON. Sessions are read by running listuser -i through the catalogued VB_UNIX.COMMAND.REV1 sub and parsing the output by whitespace + header name (robust to numeric usernames and the shared-license IP column); one listuser snapshot is cached ~5s and shared by both endpoints. Login initials (USRNAME) are resolved to full names via EMPLOYEE.PUBLIC.1287 USERID.

EndpointDescriptionAuth
GET /api/admin/system/sessionsActive UniData sessions (user, full name, type, TTY, client IP, login time)Required — UniDataSystemRead
GET /api/admin/system/licenseSeats used vs UniData:LicensedMaxUsers (80) + utilization %Required — UniDataSystemRead
GET /admin/systemHTML dashboard pageAnonymous (page shell; data calls are authed)

Auth gate: UniDataSystemRead (view) is satisfied by the app roles UniData.SystemRead/UniData.SystemAdmin, the legacy UniData.Admin role, or membership in the AD-synced IT - RF group. Destructive session kill is deliberately reserved for UniDataSystemAdmin only — read never implies kill — and is a future v2.

Service account & self-sessions: these commands need an elevated (root-level) UniData login, so they run on UniData:ServiceAccount (the dedicated API account) regardless of caller — the caller is still gated by policy and audited by UPN. Because the dashboard’s own listuser connection appears in its own output, sessions whose USRNAME equals the service account are filtered out by default (SystemStatus:HideOwnServiceAccountSessions). Spec: docs/session-license-admin-spec.md.


Batch Operations Summary

The API provides high-performance batch endpoints for pipeline and analytics workloads. All batch endpoints reuse a single UniData session, amortizing connection overhead.

EndpointMax per callUse case
POST /api/bom/dev/jobs50 jobsPipeline BOM sync, Rockwell extraction
POST /api/parts/dev/batch500 partsEnrichment workflows
POST /api/data/dev/{table}/batch5,000 recordsGeneric bulk reads (PRODUCT.NOTES, PRODUCT, etc.)
POST /api/manufacturer/dev/by-parts200 partsManufacturer enrichment
POST /api/obsolete-parts/dev/by-projects20 jobsFleet obsolescence scanning
POST /api/obsolete-parts/dev/warm-cacheUnlimitedPre-parse replacement chains

Project Structure

PSI.UniData.API/
├── PSI.UniData.API.sln
├── lib/
│   └── U2.Data.Client.dll          # Bundled for build portability
├── src/PSI.UniData.API/
│   ├── PSI.UniData.API.csproj      # .NET 8 Web API
│   ├── Program.cs                   # Startup, Windows Service hosting
│   ├── appsettings.json            # Base configuration
│   ├── Services/
│   │   ├── UniDataConnectionFactory.cs  # U2 Toolkit connections
│   │   ├── ActiveDirectoryService.cs    # AD credential lookup
│   │   ├── EncryptionService.cs         # AES credential decryption
│   │   ├── SubroutineExecutor.cs        # Generic VB_* caller
│   │   ├── BomService.cs                # BOM explosion logic
│   │   ├── PartService.cs               # Part operations
│   │   ├── DictionaryService.cs         # Schema introspection
│   │   ├── DataQueryService.cs          # Generic table queries
│   │   ├── RedbookService.cs            # RFC CRUD operations
│   │   └── RedbookLookupService.cs      # RFC lookup data
│   ├── Endpoints/
│   │   ├── BomEndpoints.cs         # /api/bom/*
│   │   ├── PartEndpoints.cs        # /api/parts/*
│   │   ├── WorkOrderEndpoints.cs   # /api/wo/*
│   │   ├── IbmEndpoints.cs        # /api/ibm/*
│   │   ├── DictionaryEndpoints.cs  # /api/dictionary/*
│   │   ├── QueryEndpoints.cs       # /api/query/*
│   │   ├── RedbookEndpoints.cs     # /api/redbook/*
│   │   └── HealthEndpoints.cs      # /api/health
│   ├── Models/
│   │   ├── BomItem.cs
│   │   ├── PartInfo.cs
│   │   ├── WorkOrder.cs
│   │   ├── TableSchema.cs
│   │   ├── FieldInfo.cs
│   │   └── Redbook/
│   │       ├── RedbookEntry.cs
│   │       ├── RedbookRequests.cs
│   │       └── LookupItem.cs
│   └── Helpers/
│       └── UniDynArrayParser.cs    # UniData result parsing
├── pipeline/                         # Nightly data build pipeline (Python)
│   ├── build_comprehensive_dataset.py  # Main: 13 sources → dataset + detail CSVs
│   ├── extract_lds_gantt.py            # LDS Excel → planned Gantt schedules
│   ├── requirements.txt                # Python deps (openpyxl)
│   └── data/
│       ├── project_customers.csv       # Static input (3,085 projects, committed)
│       └── otd_dataset.csv             # Static input (2,569 projects, committed)
├── deploy/
│   └── appsettings.Production.json # Production config template
└── .github/workflows/
    ├── deploy-ps-proxy.yml         # Auto-deployment (.NET API)
    └── nightly-data-build.yml      # Nightly dataset build (Python pipeline)

Configuration

appsettings.json (Base)

{
  "UniData": {
    "Server": "MRP-PROD",
    "Database": "/home/pro3",
    "Service": "udcs",
    "Pooling": false,
    "ConnectTimeout": 30,
    "ServiceAccount": ""
  },
  "AzureAd": {
    "Instance": "https://login.microsoftonline.com/",
    "TenantId": "YOUR_TENANT_ID",
    "ClientId": "YOUR_CLIENT_ID",
    "Audience": "api://psi-unidata-api"
  },
  "Cors": {
    "AllowedOrigins": [
      "http://localhost:5173",
      "https://bom-explorer-web.azurewebsites.net",
      "https://redbook.progressivesurface.com"
    ]
  }
}

appsettings.Production.json

{
  "UniData": {
    "ServiceAccount": "API"
  },
  "Kestrel": {
    "Endpoints": {
      "Https": {
        "Url": "https://0.0.0.0:443",
        "Certificate": {
          "Path": "C:/Services/PSI.UniData.API/certificate/wildcard.pfx",
          "Password": "cert-password"
        }
      },
      "Http": {
        "Url": "http://0.0.0.0:80"
      }
    }
  }
}

Initial Server Setup

Prerequisites

  • Windows Server 2019+ with .NET 8.0 ASP.NET Core Hosting Bundle
  • .NET 8.0 SDK (for building via GitHub Actions)
  • Network access to MRP-PROD (UniData)
  • Active Directory domain membership
  • Wildcard SSL certificate (*.progressivesurface.com)

Service Installation

# Create and configure service
sc.exe create PSI.UniData.API binPath= "C:\Services\PSI.UniData.API\PSI.UniData.API.exe" start= auto DisplayName= "PSI UniData API Gateway"
sc.exe config PSI.UniData.API obj= "AD\ServiceAccount" password= "<YOUR_PASSWORD>"
 
# Configure firewall
New-NetFirewallRule -DisplayName "PSI UniData API HTTPS" -Direction Inbound -LocalPort 443 -Protocol TCP -Action Allow
New-NetFirewallRule -DisplayName "PSI UniData API HTTP" -Direction Inbound -LocalPort 80 -Protocol TCP -Action Allow
 
# Start service
Start-Service PSI.UniData.API

GitHub Actions Runner Setup

# Download from: https://progressivesurface.ghe.com/ProgressiveSurface/PSI.UniData.API/settings/actions/runners
mkdir C:\actions-runner; cd C:\actions-runner
# Extract and configure:
.\config.cmd --url https://progressivesurface.ghe.com/ProgressiveSurface/PSI.UniData.API --token YOUR_TOKEN
# Install as service with admin account:
.\config.cmd --runasservice --windowslogonaccount AD\AdminAccount --windowslogonpassword <YOUR_PASSWORD>

Runner requirements:

  • Write access to C:\Services\PSI.UniData.API
  • Permission to stop/start the PSI.UniData.API service
  • .NET 8 SDK installed
  • Python 3.12 (installed automatically by actions/setup-python@v5 for nightly build)
  • Network access to \\ad.ptihome.com\DFS\DATA\..., \\ad.ptihome.com\DFS\LDS\PROJECT, \\ad.ptihome.com\DFS\Schedule\...

Logs

API logs are available on the network share:

\\ps-proxy\logs\
Log FileDescription
api-YYYYMMDD.logDaily API request/response log (rolled daily, 30-day retention)
startup-YYYYMMDD.logService startup diagnostics

Logs use Serilog structured logging. On-disk path: C:\Services\PSI.UniData.API\logs\

Request audit line (2026-06-25)

Every request — including failures — emits one completion line attributable to a caller. The request logging middleware sits above authentication, so 401/403/404/500 all log here; a rejected token is captured separately by the JWT failure/challenge handlers (caller stays anonymous on the completion line because auth never populated the identity).

HTTP {Method} {Path} responded {Status} in {Elapsed} ms (caller {Caller} via {AuthScheme} from {ClientIp}; query {QueryKeys}; roles {Roles}; trace {TraceId})
  • Caller — UPN for user tokens (ClaimTypes.Upnpreferred_usernameClaimTypes.Email), or the app id (azp/appid) for app/service tokens, else anonymous.
  • QueryKeys — query parameter names only (e.g. [customer,email]), never values — audit without PII leakage. (Note: ASP.NET’s own Hosting.Diagnostics “Request starting/finished” lines still echo the raw query string.)
  • Levels — 5xx → [ERR], 4xx → [WRN], success → [INF], /api/health*[DBG] (kept out of the audit stream).
  • ClientIp — Kestrel is the TLS edge on PS-PROXY (no reverse proxy), so this is the real client IP; UseForwardedHeaders is deliberately not enabled.

Quick tail of today’s log (PowerShell):

Get-Content '\\ps-proxy\logs\api-20260218.log' -Tail 50

Search for errors:

Select-String -Path '\\ps-proxy\logs\api-*.log' -Pattern '\[ERR\]|\[WRN\]' | Select-Object -Last 20

Find what a specific caller did (audit):

Select-String -Path '\\ps-proxy\logs\api-*.log' -Pattern 'responded' | Where-Object { $_ -match 'caller (jdoe@progressivesurface\.com|<app-guid>)' }

Troubleshooting

Service won’t start

  • Check Windows Event Viewer for errors
  • Check startup log: \\ps-proxy\logs\startup-YYYYMMDD.log
  • Verify .NET 8 ASP.NET Core Hosting Bundle is installed
  • Check ports aren’t in use: netstat -ano | findstr :443
  • Verify certificate path/password in appsettings.Production.json

Deployment fails with “Access denied”

  • Ensure runner service account has write access to C:\Services\PSI.UniData.API
  • Grant permissions: icacls "C:\Services\PSI.UniData.API" /grant "AD\RunnerAccount":F /T

UniData connection hangs (data requests timeout)

  • Symptom: /api/health returns 200 but /api/health/unidata and all data endpoints hang
  • Log signature: Repeated Creating UniData connection to MRP-PROD//home/pro3 for user AMD with no follow-up response
  • Resolution: Restart the PSI.UniData.API service on PS-PROXY:
    Restart-Service "PSI.UniData.API"
    # Verify it's back:
    Invoke-RestMethod https://api.progressivesurface.com/api/health
    Invoke-RestMethod https://api.progressivesurface.com/api/health/unidata
  • Note: This is an API-side issue — do not restart anything on the UniData server (MRP-PROD)

UniData connection fails

  • Verify network access to MRP-PROD from PS-PROXY
  • Check UniData:ServiceAccount has valid credentials in AD
  • Test with /api/health/unidata endpoint

Running Locally

cd C:\GIT\PSI.UniData.API\src\PSI.UniData.API
dotnet run --urls="http://localhost:5000"

Test endpoints:

curl http://localhost:5000/api/health
curl http://localhost:5000/api/bom/dev/job/95188
curl http://localhost:5000/api/redbook/dev/rfc/12345