Kinetic update broke LaborHed/LaborDtl creation from code, has the required structure changed?

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>

Where is JobHead coming into play?

@Jeff_Owens Can you respond?

@hkeric.wci … are you asking about the call in our code to: laborSvc.GetNewLaborHed1(…) to build the shape?

@Jeff_Owens The exception notes JobHead being the issue. I think he wants to know if you query or reference it at all outside of the code provided.

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.

Yes, we are invoking the JobHead to get the Job Quantity, which we then use in our ‘prodQty’ variable

Can you paste the whole code so we can see JobHead as well?

 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();

 

 }

Does it allow you to do Db.JobHead.AsNoTracking() here?

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:

It would seem that “.AsNoTracking” is not necessary 100% of the time in read-only situation, if using statements such as “.Select”

Can you ask it why his JobHead is complaining about it. He is selecting a single column.

I wonder if the syntax he is using doesnt play nice.

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.

Hi @Jonathan, the failure occurs on this line:

laborSvc.DefaultOprSeq(ref laborDataSet, jobOperation.OprSeq, out warningMsg);

May I request a bit of guidance, or reference to documentation, on how best to change the code at or before this line to implement your solution?

(Note, If I remove that line and attempt to manually populate values on the LaborDtl it just failed on the next call to update)