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?”

Datasetspsi-analytics/eto/data/project_charged_op_inventory.csv (82 op codes) · project_charged_bc_crosswalk.csv · project_charged_summary.csv
Reportpsi-analytics/eto/reports/project_labor_audit.html — every timecard entry on selected jobs, filterable
Build scriptseto/scripts/project_charged_labor.py → CSVs + per-job extracts · build_project_labor_report.py → HTML
RefreshFrom 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)
WindowAggregates 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 typeRuleWhat it means
ProjectPart == 'PROJ', WO number equals the job number, part description PROJECT WORK ORDERSNo routing, no part, no standard time. The hour is booked against the machine as a whole.
Part work orderReal part number, routed operationThe shop’s container. The only kind of hour comparable to an estimate.
WarrantySibling job <job>W, part WARRANTYPost-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 typeHours 2022+Share of job-charged labor
Charged to the project (PROJ pseudo-WO)314,72054.8%
Charged to a part work order259,07845.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

DepartmentHours 2022+Share
110M Mechanical Engineering87,39727.8%
110E Controls Engineering85,54627.2%
122 Test & Runoff74,12023.6%
120 Machine Service / MVI45,67614.5%
106 · 102 · 108 · 104 (shop)19,2616.2%
130 · 135 · 125 · 1152,3990.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:

OpDescriptionHours 2022+Share
2100MECH DESIGN PROJECT81,49525.9%
4800TEST/STARTUP61,63519.6%
2140SOFTWARE DESIGN38,95512.4%
4156INSTALLATION27,2088.7%
2120CONTROLS DESIGN25,5538.1%
2055CONTROL ADMINISTRATION13,8234.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

SuffixShare of project-charged hrsWhat it actually is
(bare job number)97.1%The job itself
R2.2% (6,973 hrs)Shop rework. 100% shop departments (106/108/104/102), all assembly / weld / electrical ops — rework with nowhere else to go.
A0.7% (2,058 hrs)Change-order “addition” work. Same op mix as the base job (2322A runs alongside 2322).
WWarranty (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:

AxisBC fieldCarries
PhaseProject TaskWhere in the machine’s life the hour sits
ActivityWork Type CodeWhat kind of hour it is (also drives chargeability and rate)
OrgDimensionDepartment / cost centre, from the resource

Proposed Project Task WBS — identical on every job, new build and retrofit

TaskHours 2022+ShareRolls up
20 Mechanical Design87,39727.8%2100, 2020, 2110, 2051
70 Test & Runoff71,26422.6%4800, 4810, 4860, 4855, 4840 +7
80 Install & Commission39,51912.6%4156, 4170, 4100, 4110
40 Software39,26112.5%2140, 2150
30 Controls Design32,52710.3%2120, 2130, 2162, 2151
60 Buildproduction orders, not a task16,8215.3%110, 100, 70, 105, 90 +29
10 Project Management14,1574.5%2055, 4600, 4220, 2050 +4
50 Mfg Engineering7,4542.4%50, 4820, 4115, 4970
99 Service & Warranty3,4741.1%4160, 5605, 4180, 5600 +3
90 Customer Training2,8470.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):

FieldValueWhat it gives you
WKCEN / CCEN / GRADE2010 / 110M / 1work centre distinct from the op code, cost centre, labor grade
RATES54.76 / 31.56 / 42.08the row carries its own rate triple
FOVFACT / COSTS31.63% / $547.60overhead factor and extended cost of those hours
PC / FPERP / 23-01posted flag, fiscal period
FWD.WO BWD.WO FWD.JOB BWD.JOB879139!001 / 880656!003linked-list pointers threading the row into the WO chain and the job chain
TIME.DATE, header DATE.UPD+TIME.UPD2023-01-16 20:02:36when 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 LABORHIST row carries RWK, RWTYPE, DELAY.CODE, DELAY.GRP, WORK.CODE, WORK.TYPE and WOCLASS — 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 appending R to 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

  1. 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.
  2. 2100 MECH DESIGN PROJECT is 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.
  3. Formalize the A suffix. 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.

JobCustomer / machineValueHoursProjectPart WOWarranty
2301GE Caledonian — robotic grit blast w/ drying$1.38M3,42149%50%19
2312P&W AutoAir — robotic plasma spray + grit blast$2.82M8,80070%27%215
2317Zimmer Biomet — plastic media mold clean #2$487k1,15723%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 typeBest design driverMedian abs errorWithin 25%
New build (n=397)first-time PHYS. parts37.2%39%
New buildorder value50.9%22%
Retrofit (n=172)order value49.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 2022 default 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.


Created: 2026-08-10 Source: psi-analytics/eto/scripts/project_charged_labor.py, build_project_labor_report.py (PR #5)