Project-Charged Labor & the Business Central Container
Where PSI’s project time actually goes, and what container it needs after the ERP migration. Answers “who is charging time to the project rather than to a work order, and why” — and “what production orders would we need in Business Central to replace that?”
| Datasets | psi-analytics/eto/data/project_charged_op_inventory.csv (82 op codes) · project_charged_bc_crosswalk.csv · project_charged_summary.csv |
| Report | psi-analytics/eto/reports/project_labor_audit.html — every timecard entry on selected jobs, filterable |
| Build scripts | eto/scripts/project_charged_labor.py → CSVs + per-job extracts · build_project_labor_report.py → HTML |
| Refresh | From the repo root: python eto\scripts\project_charged_labor.py --jobs 2301,2312,2317 then python eto\scripts\build_project_labor_report.py --jobs 2301,2312,2317 (~2 min; streams a 367 MB CSV) |
| Window | Aggregates default to 2022+ (--since to widen); entry extracts are full history |
Read this first — “non-WO time” does not exist at PSI
It is natural to describe engineering time as “not on a work order.” It is on one. Every AFTEC timecard row carries a work order; what differs is what kind of work order:
| Charge type | Rule | What it means |
|---|---|---|
| Project | Part == 'PROJ', WO number equals the job number, part description PROJECT WORK ORDERS | No routing, no part, no standard time. The hour is booked against the machine as a whole. |
| Part work order | Real part number, routed operation | The shop’s container. The only kind of hour comparable to an estimate. |
| Warranty | Sibling job <job>W, part WARRANTY | Post-shipment work. A different job number. |
So the field to filter on is Part == 'PROJ' — not a blank WO. The
AFTEC Job Costing page explains why the pseudo-WO exists at all: AFTEC’s
posting programs need a work order to resolve the job, so every job carries a job-level WO whose number
equals the job number (5,421 of them, 5,418 type N non-assembly).
Scale — this is the majority of job labor
| Charge type | Hours 2022+ | Share of job-charged labor |
|---|---|---|
| Charged to the project (PROJ pseudo-WO) | 314,720 | 54.8% |
| Charged to a part work order | 259,078 | 45.2% |
Charged to warranty (<job>W) | 8,666 | — |
It is not an engineering-department curiosity. More than half of all labor booked to a PSI job goes to the project rather than to a routed operation, and any ERP design that treats it as an exception will fail on the majority case.
Who charges it
| Department | Hours 2022+ | Share |
|---|---|---|
| 110M Mechanical Engineering | 87,397 | 27.8% |
| 110E Controls Engineering | 85,546 | 27.2% |
| 122 Test & Runoff | 74,120 | 23.6% |
| 120 Machine Service / MVI | 45,676 | 14.5% |
| 106 · 102 · 108 · 104 (shop) | 19,261 | 6.2% |
| 130 · 135 · 125 · 115 | 2,399 | 0.8% |
93.1% is four departments — design, software, commissioning and installation. This is not undisciplined charging; it is the only container AFTEC gives those departments. 80.6% of the hours are new builds, 19.3% retrofits.
What they are doing
Six op codes are 79% of it:
| Op | Description | Hours 2022+ | Share |
|---|---|---|---|
| 2100 | MECH DESIGN PROJECT | 81,495 | 25.9% |
| 4800 | TEST/STARTUP | 61,635 | 19.6% |
| 2140 | SOFTWARE DESIGN | 38,955 | 12.4% |
| 4156 | INSTALLATION | 27,208 | 8.7% |
| 2120 | CONTROLS DESIGN | 25,553 | 8.1% |
| 2055 | CONTROL ADMINISTRATION | 13,823 | 4.4% |
The remaining 76 op codes are a long tail. project_charged_op_inventory.csv has all 82 with a
SameOpOnPartWO_Hrs column — how many hours that same op books on real part work orders. That
column is the ambiguity flag: 110 MECH. ASSEMBLY books 5,529 hrs on the project WO against 50,397 on
part WOs, so 10% of assembly time is bypassing the routing.
Two traps that will bite any query — including the migration’s
1. Warranty is a separate job, and half-hidden
Warranty labor books to <job>W against part WARRANTY. Some of those rows leave the Job field
blank and carry the W number in the WO field only. Filtering Job = '2312' silently drops every
warranty hour on the job — 215 hrs on that one. Match the whole job family on either field:
base = next((b for b in jobs
if job.upper().startswith(b)
or (not job and wo.upper().startswith(b))), None)2. The pseudo-WO suffixes carry meaning — don’t flatten them
| Suffix | Share of project-charged hrs | What it actually is |
|---|---|---|
| (bare job number) | 97.1% | The job itself |
R | 2.2% (6,973 hrs) | Shop rework. 100% shop departments (106/108/104/102), all assembly / weld / electrical ops — rework with nowhere else to go. |
A | 0.7% (2,058 hrs) | Change-order “addition” work. Same op mix as the base job (2322A runs alongside 2322). |
W | — | Warranty (see above) |
Both R and A are quality- and billing-relevant categories currently tracked by naming
convention. They are the strongest argument that the shop and engineering already want these
buckets — they invented them without system support.
The Business Central design
Production orders are the wrong container for 95% of it
A BC production order needs an item, a routing, a work centre, and it outputs a quantity. Mechanical
design hours output no quantity. Forcing them in means inventing phantom items to hold hours — which
is precisely what the AFTEC PROJ work order already is. The
job-costing page states the same conclusion from the accounting side:
the PROJ work order has no BC equivalent and shouldn’t get one.
Business Central’s own design agrees. A time sheet line can be of type Project, Resource, Service, Assembly Order or Absence — there is no production-order line type, because shop time posts through the Production Journal instead. The platform expects two entry paths, and the rule for which one is simple:
If the hour makes a part, it is a production order. Otherwise it is a project task.
What genuinely becomes a production order: 5.3%
16,821 hrs of shop ops currently on the project WO, in two populations needing different fixes:
- 6,973 hrs on
<job>R→ a rework production order against the part. This finally makes rework a measurable quality cost instead of a suffix convention. - ~9,800 hrs on the bare job number → belongs on the production order that already exists for the part. A discipline fix, not a structure fix.
Two axes, because the AFTEC op code conflates two things
4170 TRAVEL gives the activity but not the phase. 4800 TEST/STARTUP gives the phase and hides that
some of it is travel. That conflation is why the data feels coarse. BC separates them, and the
department comes along free as a dimension off the resource record:
| Axis | BC field | Carries |
|---|---|---|
| Phase | Project Task | Where in the machine’s life the hour sits |
| Activity | Work Type Code | What kind of hour it is (also drives chargeability and rate) |
| Org | Dimension | Department / cost centre, from the resource |
Proposed Project Task WBS — identical on every job, new build and retrofit
| Task | Hours 2022+ | Share | Rolls up |
|---|---|---|---|
| 20 Mechanical Design | 87,397 | 27.8% | 2100, 2020, 2110, 2051 |
| 70 Test & Runoff | 71,264 | 22.6% | 4800, 4810, 4860, 4855, 4840 +7 |
| 80 Install & Commission | 39,519 | 12.6% | 4156, 4170, 4100, 4110 |
| 40 Software | 39,261 | 12.5% | 2140, 2150 |
| 30 Controls Design | 32,527 | 10.3% | 2120, 2130, 2162, 2151 |
| 60 Build → production orders, not a task | 16,821 | 5.3% | 110, 100, 70, 105, 90 +29 |
| 10 Project Management | 14,157 | 4.5% | 2055, 4600, 4220, 2050 +4 |
| 50 Mfg Engineering | 7,454 | 2.4% | 50, 4820, 4115, 4970 |
| 99 Service & Warranty | 3,474 | 1.1% | 4160, 5605, 4180, 5600 +3 |
| 90 Customer Training | 2,847 | 0.9% | 4154, 4830, 4105, 5620 |
Proposed Work Type Codes
DESIGN 47.8% · TEST 21.3% · INSTALL 9.0% · SHOP 5.3% · ENG-ADMIN 4.4% · TRAVEL 3.9% · DOC 2.5% · PROGRAM 2.4% · DEBUG 1.3% · SERVICE 1.0% · TRAIN 0.9% · R&D and PM <0.2%.
The immediate payoff: 12,359 hrs of travel, today buried inside installation and startup, becomes its own reportable line without adding a single task.
What a timecard entry actually contains — and the seven empty fields
Verified against the live UniData schema, 2026-08-10. There is no note, comment or description field
anywhere on a labor line — not in the entry file TS.1287, not in the posted history file
LABORHIST. An engineer books date, job, work order, op code and hours. That is the whole input. The
only free text in the structure is MISC.NOTE / OFF.DUTY.NOTE on the day header — absence reasons,
not work descriptions (OFF.DUTY.NOTE used 4 times in a 217-record sample; MISC.NOTE never).
What the line does carry, none of which reaches tslabor2.csv — from the real record
LABORHIST 1!880506!002 (10 hrs of mech design on job 2301, 2023-01-16):
| Field | Value | What it gives you |
|---|---|---|
WKCEN / CCEN / GRADE | 2010 / 110M / 1 | work centre distinct from the op code, cost centre, labor grade |
RATES | 54.76 / 31.56 / 42.08 | the row carries its own rate triple |
FOVFACT / COSTS | 31.63% / $547.60 | overhead factor and extended cost of those hours |
PC / FPER | P / 23-01 | posted flag, fiscal period |
FWD.WO BWD.WO FWD.JOB BWD.JOB | 879139!001 / 880656!003 | linked-list pointers threading the row into the WO chain and the job chain |
TIME.DATE, header DATE.UPD+TIME.UPD | 2023-01-16 20:02:36 | when it was keyed, versus when the work happened |
Every timecard line is a fully costed GL transaction, not an hours record. The day header
(LABORHIST.HEAD 1!880506) shows the posting: $547.60 credit 5015-110 (labor) plus $173.21 credit
7400-110 (overhead), both debited to WIP 1235-000 under GL.DOCNO 126153. The overhead ties
exactly — 547.60 × 31.63% = 173.21 — which independently confirms the field scaling rather than
assuming it.
The seven empty fields are the most important thing on this page. Every
LABORHISTrow carriesRWK,RWTYPE,DELAY.CODE,DELAY.GRP,WORK.CODE,WORK.TYPEandWOCLASS— a rework flag and a delay-reason code on every hour ever booked. In a sampled 100 records, all seven are populated zero times. The schema was never the constraint; the entry screen was. That is why the shop encodes rework by appendingRto a job number. Migrating to BC changes nothing unless the time sheet actually asks — the lever is BC Project Planning Lines, where a planner sets task and work type once and the engineer copies their own plan into the time sheet, so the category comes from the plan rather than from recall.
Three things that make or break it
- Keep the task list at ten. Ten tasks × thirteen work types is 130 valid combinations — plenty of resolution. Offer forty tasks and people pick wrong; you get AFTEC-quality data at BC licensing cost.
2100 MECH DESIGN PROJECTis 25.9% of everything on one code. If the new scheme only renames it, nothing improves. This is the one place to add depth — split by machine subsystem or design gate (concept / detail / release). Everywhere else, less depth is better.- Formalize the
Asuffix. Change-order work is what PSI invoices from; it should be a task group per change order, not a naming convention.
Reference case — three completed 23xx new builds
All three past their warranty end date, so the labor record is closed and nothing more will be charged.
| Job | Customer / machine | Value | Hours | Project | Part WO | Warranty |
|---|---|---|---|---|---|---|
| 2301 | GE Caledonian — robotic grit blast w/ drying | $1.38M | 3,421 | 49% | 50% | 19 |
| 2312 | P&W AutoAir — robotic plasma spray + grit blast | $2.82M | 8,800 | 70% | 27% | 215 |
| 2317 | Zimmer Biomet — plastic media mold clean #2 | $487k | 1,157 | 23% | 75% | 24 |
Project-charged share tracks engineering content, not job size. 2312 was a custom booth — 1,829 hrs of mech design, 1,095 of software, 686 of controls design, all straight to the project. 2317 was the second copy of an existing machine, so three-quarters of its hours ran through routed part work orders. Hours per $1M barely moves (2,375–3,122); the mix moves enormously.
Post-ship tails differ just as sharply: 2301 stopped dead at ship (99% pre-ship, 26 hrs after), while 2312 carried 772 hrs past ship — installation, travel and field service — with warranty trickling to April 2026, two years later.
Giving the tasks a budget — and what actually drives design hours
eto/scripts/task_budget_baseline.py derives per-task hour budgets from 572 closed machine builds
(shipped 2015+, ≥270 days settled, ≥$50k, external accounts), grouped on ProcessOneName and split new
build vs retrofit → task_budget_baseline.csv, task_budget_by_job.csv, task_budget_design_driver.csv.
Read the back-test before quoting any of it. Leave-one-out on total build hours: 23.0% median absolute error by process family against 24.7% for a naive build-type-only model — segmentation buys 1.6 points, which is marginal — and only 53% of jobs land within 25% of actual. Mechanical Design is 56% off. A value-driven hour budget is not a number to hold an engineer to.
Two things do work.
1. Predict the mix, not the scale. Given a job’s total, allocating by the cell’s median share beats the value model on every engineering task — Mechanical Design 56.1% → 42.1%, Controls 42.9% → 33.3%, Software 54.1% → 45.7%, Test 32.2% → 25.0%. PSI already estimates a total at quote, so the useful artifact is an allocation model, which is exactly the shape of a BC Project Planning Line.
2. Design hours are driven by new-part count, not by price. Using
redbook_coq.db.project_reuse_metrics at level PHYS. — how many PHYS.-level parts on the machine had
never appeared on a prior job:
| Build type | Best design driver | Median abs error | Within 25% |
|---|---|---|---|
| New build (n=397) | first-time PHYS. parts | 37.2% | 39% |
| New build | order value | 50.9% | 22% |
| Retrofit (n=172) | order value | 49.0% | 32% |
13.8 points better than order value on new builds (r = +0.775 vs +0.622), and in units anyone can argue with: a Pressure Blast new build runs ~22 mechanical design hours per new PHYS. part (p25–p75 14.9–31.3), ~7 controls, ~7 software.
Why the driver must be build-type dependent: a retrofit reuses an existing machine, so every PHYS.
part has been seen before — reuse_rate pins at 1.0 and first_time_parts at 0 for 84% of
retrofits versus 11% of new builds. The metric is definitionally blind there.
Two candidate drivers were tested and rejected, recorded so nobody re-tries them: BOM size
(BOM_DistinctParts, r = 0.349 vs 0.794 for order value on total hours) and the AFTEC lineage
fields — live PROJECT.1287 has REF.PROJ.NO, ORIG.PROJ and SUB.PROJECTS, populated on 44 of
3,146, 0 and 0 records respectively, and none are carried in Project1287List.xml. Explicit
“copy of <job>” references appear in the description of just 10 projects. Same pattern as the seven
empty categorization fields above: AFTEC models the relationship, nobody populates it.
Known limitations
- Labor hours only. No purchased material, outside services or vendor cost. This is not job cost — for that see AFTEC Job Costing & WIP.
- The
--since 2022default is the current operating model. Widen it and the granular indirect op-code regime change (2014–15) starts to matter; see weld shop utilization. 2055 CONTROL ADMINISTRATION(4.4%) is a judgment call. It is dept 110E and could sit under Controls Design instead of Project Management. Named here so the choice is visible, not buried.- The task/work-type split is a proposal, not a configured design. Whether the BC Projects module is inside the signed Sikich SOW determines whether this is configuration or a change order.
- Not in the nightly pipeline — manually triggered, so the artifacts go stale silently. Re-run before presenting. The report stamps its own source vintage in the header.
Related
- AFTEC Job Costing & WIP — how cost (not just hours) reaches a job, and why the
PROJWO exists - Weld Shop Utilization — why
labor_detailcan’t be used here, and the rawdgwreportfilesexport map - Quarterly Business Metrics — same repo, shares
lib/psi_data.py - Project1287 Data Patterns — read before writing anything against
PROJECT.1287 - Terminology — job number conventions (4-digit new build, ≥10000 retrofit)
Created: 2026-08-10
Source: psi-analytics/eto/scripts/project_charged_labor.py, build_project_labor_report.py (PR #5)