Quick background: we don’t record labor on the floor per job, so to satisfy
Epicor’s requirement we auto-assign it with a BPM at shipment. The BPM creates
a LaborHed and LaborDtl for each Asm/Oper being shipped. That’s worked for
years, and it’s what let Auto Job Closing run clean.
The recent Kinetic update broke it. Has anything changed about how you’re
supposed to create Labor entries from code?
We’ve got the Entity Framework side handled queries are materialized with
.ToList()/.FirstOrDefault() before the next one opens but the LaborDtl
Update still throws. Original code and the job data are below.
Note: JSON is anonymized.
foreach (var jobAssembly in jobAssemblies)
{
if (jobAssembly == null)
continue;
// MARS fix: materialize open operations before iterating.
// Only non-backflush ("B") operations that aren't complete.
var jobOperations = (from jo in Db.JobOper
where jo.Company == Session.CompanyID
&& jo.JobNum == shipDetail.JobNum
&& jo.AssemblySeq == jobAssembly.AssemblySeq
&& jo.LaborEntryMethod != "B"
&& jo.OpComplete == false
select jo).ToList();
foreach (var jobOperation in jobOperations)
{
if (jobOperation == null)
continue;
// Labor qty scaled by shipped proportion of the job.
decimal qtyPerUnit = jobOperation.RunQty / prodQty;
decimal laborQty = shipDetail.OurJobShipQty \* qtyPerUnit;
var laborDataSet = new Erp.Tablesets.LaborTableset();
laborSvc.GetNewLaborHed1(ref laborDataSet, "TEAK", false, DateTime.Today);
var laborHedRow = (from lh in laborDataSet.LaborHed
where lh.RowMod == "A"
select lh).FirstOrDefault();
laborSvc.Update(ref laborDataSet);
if (laborHedRow == null)
continue;
// Build the labor detail against this job/assembly/operation.
laborSvc.GetNewLaborDtl(ref laborDataSet, laborHedRow.LaborHedSeq);
laborSvc.DefaultJobNum(ref laborDataSet, shipDetail.JobNum);
laborSvc.DefaultAssemblySeq(ref laborDataSet, jobAssembly.AssemblySeq);
laborSvc.DefaultOprSeq(ref laborDataSet, jobOperation.OprSeq, out warningMsg); // fails here. see error logs below
laborSvc.DefaultLaborQty(ref laborDataSet, laborQty, out warningMsg);
laborSvc.CheckWarnings(ref laborDataSet, out warningMsg);
laborSvc.Update(ref laborDataSet);
var laborDtlRow = (from ld in laborDataSet.LaborDtl
select ld).FirstOrDefault();
if (laborDtlRow == null)
continue;
laborDtlRow.RowMod = "U";
laborSvc.ValidateChargeRateForTimeType(ref laborDataSet, out warningMsg);
laborSvc.SubmitForApproval(ref laborDataSet, false, out warningMsg);
laborSvc.Update(ref laborDataSet);
}
<Exception><![CDATA[System.InvalidOperationException: The instance of entity type 'JobHead' cannot be tracked because another instance with the same key value for {'SysRowID'} is already being tracked. When attaching existing entities, ensure that only one entity instance with a given key value is attached. Consider using 'DbContextOptionsBuilder.EnableSensitiveDataLogging' to see the conflicting key values.
...( omitted for brevity)
</Exception>
Also has Epicor published best practices guide how someone should handle their BPMs going forward?
Do we need .AsNoTracking() on every table we intend to only read from? It may be dumb but we may need .AsNoTracking() on every single table we query, if its just for reading.
Db.JobHead.AsNoTracking().Where(x => ........)
Perhaps all the 2026 guides are up-to-date like their BPM Cookbook. They wouldn’t release something with the documentation for it being up-to-date would they @klincecum
Their Widgets should do it right, we should be able to see the widget generate proper code. I dont have 2026, anyone on-prem can review some widgets. @Olga
We do hit the ShipHead further up the code (not present) but the issue doesn’t throw bubble up / throw until we hit this helper method:
laborSvc.DefaultOprSeq(ref laborDataSet, jobOperation.OprSeq, out warningMsg);
I’ll add the AsNoTracking() and respond with the results if it presents a solution
Are you invoking Db.JobHead anywhere, or is it just a product of LaborService somewhere internally invoking Db.JobHead I wasnt sure if your code was half-pasted.
Just to follow up on the previous comment. The Data Directory is built on top of the ShipHead for the ShipHead object is passed in from Epicor. I am not able to add .AsNoTracking() to the ShipHead passed as a parameter to BPM.
Erp.Contracts.LaborSvcContract laborSvc =
Ice.Assemblies.ServiceRenderer.GetService<Erp.Contracts.LaborSvcContract(Db);
if (laborSvc == null)
return;
string warningMsg = string.Empty;
using (var txScope = IceContext.CreateDefaultTransactionScope())
{
// Current record context: iterate the ShipHead temp table.
foreach (var shipHead in ttShipHead)
{
if (shipHead == null)
continue;
// NOTE: original behavior preserved - ship details are read from the
// committed Db.ShipDtl rows (filtered by PackNum), not the ttShipDtl
// temp table. MARS fix: .ToList() closes this reader before we open the
// JobAsmbl reader nested below.
var shipDetails = (from sd in Db.ShipDtl
where sd.Company == Session.CompanyID
&& sd.PackNum == shipHead.PackNum
select sd).ToList();
foreach (var shipDetail in shipDetails)
{
if (shipDetail == null)
continue;
var jobHead = (from jh in Db.JobHead
where jh.Company == Session.CompanyID
&& jh.JobNum == shipDetail.JobNum
select new { jh.ProdQty }).FirstOrDefault();
decimal prodQty = jobHead?.ProdQty ?? decimal.Zero;
if (prodQty <= decimal.Zero)
continue;
var jobAssemblies = (from ja in Db.JobAsmbl
where ja.Company == Session.CompanyID
&& ja.JobNum == shipDetail.JobNum
select ja).ToList();
foreach (var jobAssembly in jobAssemblies)
{
if (jobAssembly == null)
continue;
// Only non-backflush ("B") operations that aren't complete.
var jobOperations = (from jo in Db.JobOper
where jo.Company == Session.CompanyID
&& jo.JobNum == shipDetail.JobNum
&& jo.AssemblySeq == jobAssembly.AssemblySeq
&& jo.LaborEntryMethod != "B"
&& jo.OpComplete == false
select jo).ToList();
foreach (var jobOperation in jobOperations)
{
if (jobOperation == null)
continue;
// Labor qty scaled by shipped proportion of the job.
decimal qtyPerUnit = jobOperation.RunQty / prodQty;
decimal laborQty = shipDetail.OurJobShipQty \* qtyPerUnit;
var laborDataSet = new Erp.Tablesets.LaborTableset();
laborSvc.GetNewLaborHed1(ref laborDataSet, "TEAK", false, DateTime.Today);
var laborHedRow = (from lh in laborDataSet.LaborHed
where lh.RowMod == "A"
select lh).FirstOrDefault();
laborSvc.Update(ref laborDataSet);
if (laborHedRow == null)
continue;
// Build the labor detail against this job/assembly/operation.
laborSvc.GetNewLaborDtl(ref laborDataSet, laborHedRow.LaborHedSeq);
laborSvc.DefaultJobNum(ref laborDataSet, shipDetail.JobNum);
laborSvc.DefaultAssemblySeq(ref laborDataSet, jobAssembly.AssemblySeq);
laborSvc.DefaultOprSeq(ref laborDataSet, jobOperation.OprSeq, out warningMsg); // (1) it breaks here
laborSvc.DefaultLaborQty(ref laborDataSet, laborQty, out warningMsg);
laborSvc.CheckWarnings(ref laborDataSet, out warningMsg);
laborSvc.Update(ref laborDataSet); // if we remove (1) and attempt to populate data manually, then it breaks here
var laborDtlRow = (from ld in laborDataSet.LaborDtl
select ld).FirstOrDefault();
if (laborDtlRow == null)
continue;
laborDtlRow.RowMod = "U";
laborSvc.ValidateChargeRateForTimeType(ref laborDataSet, out warningMsg);
laborSvc.SubmitForApproval(ref laborDataSet, false, out warningMsg);
laborSvc.Update(ref laborDataSet);
}
}
}
}
Db.SaveChanges();
txScope.Complete();
}
Learning something new everyday, was not aware of “.AsNoTracking”. This whole EFCore thing forces me to realize that I really don’t know what I’m doing.
Prompt Gemini about it and this part is interesting:
For conversation scope, it seems like there’s something going on behind the scenes that we aren’t aware of since the new version came out. This code has been running fine for years, and should have continued after the update with the EF changes, but we still get the exception when iterating the JobDtl.
From a code perspective things look solid, that’s why it’s such a mystery. One hypothesis we had was if the Time Expense change to Time & Entry introduced some new properties or structure that this code isn’t aware of. Is that a thing? We’re grasping for straws out here.
To make matters worse, we’ve engaged Professional Services as a priority issue TWO WEEKS and the assigned manager has made two blunders causing wasted time – we’re still waiting for a resource from them and can’t close over 4000 jobs as a result (we do a LOT of build to order volume and human T&E to resolve this isn’t an option).
Epicor changed from Entity Framework 6 to Entity Framework Core
If you look on the forums the last 2 weeks alot of things have been breaking for everyone.
EF Core is very sensitive. If you have 2 BPMs touching the same table itll blow up.
If your BPM touches JobHead and then Labor Service internally touches JobHead it may break.
Not sure what the fix is yet. Epicor doesnt know either. Thats rhe worst part. They dont know how people should uplift their BPMs. They are guessing. Try this try that… The whole BPM feature may be broken. The only fix may be, dont customize anymore.
Putting the EF Core error aside, when calling Update make sure to send two rows, the unchanged one without rowmod and the updated one with rowmod U.
The code sometimes uses the absence of the unchanged row as meaning the record is new, without looking at the rowmod.
If you still get the error after fixing that, you can try reviewing the “tracked” entities after each server call to try and narrow down where the error happening.
Knowing when the entity starts being “tracked” and what call tries to add another entity triggering the error, you could detach de entity before that.
What type of BPM is this? Ive found a lot of issues with In-Transaction data directives can resolved by moving/reconfiguring the code as standard directive instead. Similarly, moving pre processing directives to post processing can help.