E10 REST API — JobEntrySvc/JobMtls — Cannot add Material to Job via API unless material already exist,

Greetings,

I am having issues with the REST API in E10; specifically related to adding materials to a Job when the Job has no material on the material list yet.

Problem: Via the REST API, I am able to add or remove material (parts) from a material list; but only if that job already has material on the material list.

If a Job has no material lines on the material list yet, and I attempt to add materials to the Job, the REST API will fail.

Side Note: I have been working with the REST API a lot, and, quite frankly, I find it to be somewhat unstable; and error logs are pretty much useless as they do not include any helpful traces.

Below is an example payload I am sending to the Erp.Bo.JobEntrySvc/JobMtls API endpoint:

Epicor JobMtls Request Body: {
  "Company": "1000",
  "JobNum": "Project12345",
  "AssemblySeq": 1,
  "PartNum": "12345a",
  "RequiredQty": "1",
  "QtyPer": "1",
  "Description": "Example Part Description",
  "RowMod": "U",
  "RelatedOperation": "1",
  "IUM": "EA",
}

I am thinking that this could be an issue with line items (possibly that the REST API does not know which line-item to start at); although Epicor has defaulted to increments of 10 for line-item counts when adding material to a material list which already has parts attached — but that is pure speculation.

1 - Part 12345a
2 - Part 6789a
10 - Part 2468b
20 - Part 3579a

Maybe I am missing something, so I figured I would reach out to this forum. Any help or guidance here would be greatly appreciated. Thank you.

Try "RowMod": "A" instead.

While the errors are less than helpful and documentation is lacking, the api is pretty robust and stable.

The first thing you should do when trying out a new service is a trace log for what order things need to be called in. Its a good idea to use the getNew($Object) calls if you arent entirely sure of what is required.

GetNewJobMtl in this case.

The “OnChanging” or “OnChanged” are also nice little helper functions.

Row Mods are the easiest thing to mess up.
“A” is always add/append
“U” is update. “U” sometimes works, sometimes doesnt when you are actually trying to do an “A” type call.

For adding materials to jobs, I strongly recommend using and keeping track of
“MtlSeq”: 0,

Here is what I use when adding a job material through my web app.

You can remove changeJobHeadJobReleased and JobEngineered if thats not need for your company.

const epicorURL = "Your URL"
const jobEntryURL = epicorURL + "Erp.BO.JobEntrySvc/"
const encode = (str: string): string => Buffer.from(str, 'binary').toString('base64'); //Encode string to base64

export interface UserData {
    UserID: string
    Pass: string
    Name: string
    Privileges: string
    Loaded: boolean
}

export function jobAddMaterialCall(jobNumber: string, partNum: string, attributeSetID: string | null, qty: string, uom: string, byPieces: boolean, userData: UserData, setter: (success: boolean) => void) {
    const headers: Headers = new Headers()
    headers.set('Content-Type', 'application/json')
    headers.set('Accept', 'application/json')
    headers.set('Authorization', "Basic " + encode(userData.UserID + ":" + userData.Pass))

    // GetByID
    const getByID = (body: any) => {
        return (new Promise((resolve, reject) => {
            const getByID_Request: RequestInfo = new Request((jobEntryURL + "GetByID?jobNum=" + jobNumber), {
                method: 'Get',
                headers: headers,
            })
            console.log("GetByID")
            fetch(getByID_Request)
                .then((res) => {
                    if (res.status == 200) {
                        return res.json()
                    } else {
                        return res.json().then(text => { throw new Error(text["ErrorMessage"]) })
                    }
                })
                .then((data) => {
                    console.log(data)
                    var body = data["returnObj"]
                    resolve(body)
                })
                .catch((error) => {
                    console.log(error)
                    reject(error)
                })
        }))
    }


    // GetNewJobMtl
    const getNewJobMtl = (body: any) => {
        return (new Promise((resolve, reject) => {
            var obj = {
                "ds": body,
                "jobNum": jobNumber,
                "assemblySeq": "0",
            }
            const getnewJobMtlRequest: RequestInfo = new Request((jobEntryURL + "GetNewJobMtl"), {
                method: 'POST',
                headers: headers,
                body: JSON.stringify(obj),
            })
            console.log("GetNewJobMtl")
            fetch(getnewJobMtlRequest)
                .then((res) => {
                    if (res.status == 200) {
                        return res.json()
                    } else {
                        return res.json().then(text => { throw new Error(text["ErrorMessage"]) })
                    }
                })
                .then((data) => {
                    console.log(data)
                    var body = data["parameters"]["ds"]
                    resolve(body)
                })
                .catch((error) => {
                    console.log(error)
                    reject(error)
                })
        }))
    }

    // ChangeJobHeadJobReleased
    const changeJobHeadJobReleased = (body: any, released: boolean) => {
        return (new Promise((resolve, reject) => {
            var obj = {
                "ds": body,
            }
            obj["ds"]["JobHead"][0]["JobReleased"] = released
            obj["ds"]["JobHead"][0]["RowMod"] = "U"
            const changeJobHeadJobReleasedRequest: RequestInfo = new Request((jobEntryURL + "ChangeJobHeadJobReleased"), {
                method: 'POST',
                headers: headers,
                body: JSON.stringify(obj),
            })
            console.log("ChangeJobHeadJobReleased")
            fetch(changeJobHeadJobReleasedRequest)
                .then((res) => {
                    if (res.status == 200) {
                        return res.json()
                    } else {
                        return res.json().then(text => { throw new Error(text["ErrorMessage"]) })
                    }
                })
                .then((data) => {
                    console.log(data)
                    var body = data["parameters"]["ds"]
                    resolve(body)
                })
                .catch((error) => {
                    console.log(error)
                    reject(error)
                })
        }))
    }

    // ChangeJobHeadJobEngineered
    const changeJobHeadJobEngineered = (body: any, engineered: boolean) => {
        return (new Promise((resolve, reject) => {
            var obj = {
                "ds": body,
                "jobNum": jobNumber,
                "assemblySeq": "0",
            }
            obj["ds"]["JobHead"][0]["JobEngineered"] = engineered
            obj["ds"]["JobHead"][0]["RowMod"] = "U"
            const changeJobHeadJobEngineeredRequest: RequestInfo = new Request((jobEntryURL + "ChangeJobHeadJobEngineered"), {
                method: 'POST',
                headers: headers,
                body: JSON.stringify(obj),
            })
            console.log("ChangeJobHeadJobEngineered")
            fetch(changeJobHeadJobEngineeredRequest)
                .then((res) => {
                    if (res.status == 200) {
                        return res.json()
                    } else {
                        return res.json().then(text => { throw new Error(text["ErrorMessage"]) })
                    }
                })
                .then((data) => {
                    console.log(data)
                    var body = data["parameters"]["ds"]
                    resolve(body)
                })
                .catch((error) => {
                    console.log(error)
                    reject(error)
                })
        }))
    }


    // ChangeJobMtlPartNum
    const changeJobMtlPartNum = (body: any) => {
        return (new Promise((resolve, reject) => {
            var obj = {
                "ds": body,
                "ipValidatePart": true,
                "ipPartNum": partNum,
                "SysRowID": "00000000-0000-0000-0000-000000000000",
                "xrefPartNum": "",
                "xrefPartType": ""
            }
            const ChangeJobMtlPartNumRequest: RequestInfo = new Request((jobEntryURL + "ChangeJobMtlPartNum"), {
                method: 'POST',
                headers: headers,
                body: JSON.stringify(obj),
            })
            console.log("ChangeJobMtlPartNum")
            fetch(ChangeJobMtlPartNumRequest)
                .then((res) => {
                    if (res.status == 200) {
                        return res.json()
                    } else {
                        return res.json().then(text => { throw new Error(text["ErrorMessage"]) })
                    }
                })
                .then((data) => {
                    console.log(data)
                    var body = data["parameters"]["ds"]
                    resolve(body)
                })
                .catch((error) => {
                    console.log(error)
                    reject(error)
                })
        }))
    }

    // ChangeJobMtlAttributeSetID
    const changeJobMtlAttributeSetID = (body: any) => {
        return (new Promise((resolve, reject) => {
            var obj = {
                "attributeSetID": attributeSetID,
                "ds": body,
            }

            var ind = 0;
            for (var i = 0; i < obj["ds"]["JobMtl"].length; i++) {
                if (obj["ds"]["JobMtl"][i]["PartNum"] == partNum) {
                    ind = i
                }
            }
            obj["ds"]["JobMtl"][ind]["AttributeSetID"] = attributeSetID
            obj["ds"]["JobMtl"][ind]["RowMod"] = "A"

            const ChangeJobMtlAttributeSetIDRequest: RequestInfo = new Request((jobEntryURL + "ChangeJobMtlAttributeSetID"), {
                method: 'POST',
                headers: headers,
                body: JSON.stringify(obj),
            })
            console.log("ChangeJobMtlAttributeSetID")
            fetch(ChangeJobMtlAttributeSetIDRequest)
                .then((res) => {
                    if (res.status == 200) {
                        return res.json()
                    } else {
                        return res.json().then(text => { throw new Error(text["ErrorMessage"]) })
                    }
                })
                .then((data) => {
                    console.log(data)
                    var body = data["parameters"]["ds"]
                    resolve(body)
                })
                .catch((error) => {
                    console.log(error)
                    reject(error)
                })
        }))
    }

    // ChangeJobMtlQtyPer
    const changeJobMtlQtyPer = (body: any) => {
        return (new Promise((resolve, reject) => {
            var obj = {
                "ds": body
            }
            var ind = 0;
            for (var i = 0; i < obj["ds"]["JobMtl"].length; i++) {
                if (obj["ds"]["JobMtl"][i]["PartNum"] == partNum) {
                    ind = i
                }
            }
            obj["ds"]["JobMtl"][ind]["QtyPer"] = qty
            const ChangeJobMtlQtyPerRequest: RequestInfo = new Request((jobEntryURL + "ChangeJobMtlQtyPer"), {
                method: 'POST',
                headers: headers,
                body: JSON.stringify(obj),
            })
            console.log("ChangeJobMtlQtyPer")
            fetch(ChangeJobMtlQtyPerRequest)
                .then((res) => {
                    if (res.status == 200) {
                        return res.json()
                    } else {
                        return res.json().then(text => { throw new Error(text["ErrorMessage"]) })
                    }
                })
                .then((data) => {
                    console.log(data)
                    var body = data["parameters"]["ds"]
                    resolve(body)
                })
                .catch((error) => {
                    console.log(error)
                    reject(error)
                })
        }))
    }

    //onChangingNumberOfPieces
    const onChangingNumberOfPieces = (body: any) => {
        return (new Promise((resolve, reject) => {
            console.log("onChangingNumberOfPieces -> " + qty)
            var obj = {
                "numberOfPieces": qty,
                "ds": body
            }
            var ind = 0;
            for (var i = 0; i < obj["ds"]["JobMtl"].length; i++) {
                if (obj["ds"]["JobMtl"][i]["PartNum"] == partNum) {
                    ind = i
                }
            }
            obj["ds"]["JobMtl"][ind]["NumberOfPieces"] = qty
            obj["ds"]["JobMtl"][ind]["RowMod"] = "A"
            const request: RequestInfo = new Request(jobEntryURL + "OnChangingNumberOfPieces", {
                method: 'POST',
                headers: headers,
                body: JSON.stringify(obj)
            })
            fetch(request)
                .then((res) => {
                    if (res.status == 200) {
                        return res.json()
                    } else {
                        return res.json().then(text => { throw new Error(text["ErrorMessage"]) })
                    }
                })
                .then((data) => {
                    console.log(data)
                    var body = data["parameters"]["ds"]
                    console.log(body)
                    resolve(body)
                })
                .catch((error) => {
                    console.log(error)
                    reject(error)
                })
        }))
    }


    // ChangeJobMtlEstSplitCosts
    const changeJobMtlEstSplitCosts = (body: any) => {
        return (new Promise((resolve, reject) => {
            var obj = {
                "ds": body
            }
            const ChangeJobMtlEstSplitCostsRequest: RequestInfo = new Request((jobEntryURL + "ChangeJobMtlEstSplitCosts"), {
                method: 'POST',
                headers: headers,
                body: JSON.stringify(obj),
            })
            console.log("ChangeJobMtlEstSplitCosts")
            fetch(ChangeJobMtlEstSplitCostsRequest)
                .then((res) => {
                    if (res.status == 200) {
                        return res.json()
                    } else {
                        return res.json().then(text => { throw new Error(text["ErrorMessage"]) })
                    }
                })
                .then((data) => {
                    console.log(data)
                    var body = data["parameters"]["ds"]
                    console.log(body)
                    resolve(body)
                })
                .catch((error) => {
                    console.log(error)
                    reject(error)
                })
        }))
    }


    // ChangeJobMtlFixedQty
    const changeJobMtlFixedQty = (body: any) => {
        return (new Promise((resolve, reject) => {
            var obj = {
                "ds": body
            }
            var ind = 0;
            for (var i = 0; i < obj["ds"]["JobMtl"].length; i++) {
                if (obj["ds"]["JobMtl"][i]["PartNum"] == partNum) {
                    ind = i
                }
            }
            console.log("ChangeJobMtlFixedQty")
            obj["ds"]["JobMtl"][ind]["FixedQty"] = true;
            const ChangeJobMtlFixedQtyRequest: RequestInfo = new Request((jobEntryURL + "ChangeJobMtlFixedQty"), {
                method: 'POST',
                headers: headers,
                body: JSON.stringify(obj),
            })
            fetch(ChangeJobMtlFixedQtyRequest)
                .then((res) => {
                    if (res.status == 200) {
                        return res.json()
                    } else {
                        return res.json().then(text => { throw new Error(text["ErrorMessage"]) })
                    }
                })
                .then((data) => {
                    console.log(data)
                    var body = data["parameters"]["ds"]
                    console.log(body)
                    resolve(body)
                })
                .catch((error) => {
                    console.log(error)
                    reject(error)
                })
        }))
    }


    // Update
    const update = (body: any) => {
        return (new Promise((resolve, reject) => {
            var obj = {
                "ds": body
            }
            const updateRequest: RequestInfo = new Request((jobEntryURL + "Update"), {
                method: 'POST',
                headers: headers,
                body: JSON.stringify(obj),
            })
            console.log("Update")
            fetch(updateRequest)
                .then((res) => {
                    if (res.status == 200) {
                        return res.json()
                    } else {
                        return res.json().then(text => { throw new Error(text["ErrorMessage"]) })
                    }
                })
                .then((data) => {
                    console.log(data)
                    var body = data["parameters"]["ds"]
                    console.log(body)
                    resolve(body)
                })
                .catch((error) => {
                    console.log(error)
                    reject(error)
                })
        }))
    }

    const finishingBits = (body: any) => {
        changeJobMtlEstSplitCosts(body)
            .then((body) => changeJobMtlFixedQty(body))
            .then((body) => update(body))
            .then((body) => changeJobHeadJobEngineered(body, true))
            .then((body) => {
                changeJobHeadJobReleased(body, true)
            })
            .then((body) => { setter(true) })
    }

    getByID(jobNumber)
        .then((body) => changeJobHeadJobReleased(body, false))
        .then((body) => changeJobHeadJobEngineered(body, false))
        .then((body) => getNewJobMtl(body))
        .then((body) => changeJobMtlPartNum(body))
        .then((body) => {
            if (attributeSetID?.toString() != "0") {
                changeJobMtlAttributeSetID(body)
                    .then((body) => {
                        if (byPieces) {
                            onChangingNumberOfPieces(body)
                                .then((body) => { finishingBits(body) })
                        } else {
                            finishingBits(body)
                        }
                    })
            } else {
                changeJobMtlQtyPer(body)
                    .then((body) => { finishingBits(body) })
            }
        })
        .catch(() => {
            setter(false)
        })
}


Ahh, this makes sense.

To @Jgrissom’s point regarding “U sometimes works for adding” — I think that this made finding the real cause difficult because I assumed (incorrectly) that RowMod: “U” was the proper way to append/add/update — as I have been using “U” for adds, removals, and updates successfully for some time; just not when the Job does not already have material.

That said,

  1. I updated my code to use RowMod: “A” for Adds, and “U” for basically everything else. This worked for all Jobs where material already exist on the Job.
  2. Using RowMod: “A” did not work when attempting to add material to a Job which does not yet have material.

Failed to add job material: 500 Internal Server Error. Response: Sorry! Something went wrong. Please contact your system administrator.

So, although I will move forwards with the suggestions you both made to use RowMod: “A” for adding new material lines; this did not solve the underlying issue.

I am unsure if this is related, but I wonder if my schedule not containing any Week Ops could cause the part to fail to be added; but again, that is pure speculation.


(Fig. 1 — Job ASM 1 showing no schedule operations or materials after attempting to add parts using RowMod: “A”).

I do appreciate this feedback. I may try to dig into the traces here in a moment.

Another thing to try is adding the MtlSeq field, even if it’s just 0. The endpoint you’re using is the REST OData endpoint, not the RPC methods that most people here are going to assume you’re using.

When posting to JobMtls, you’re internally using UpdateExt. This is a funky version of Update that works well when it does, but has some conditions. One of those is that it requires all of the key fields to be populated.

FYI, I’m assuming this is on-prem. Most REST errors should get put into the event viewer logs.

Nah, you dont need any ops for adding materials or such.

You really do want to use the workflow that I posted in typescript above.

If you dont, youll end up with a function that probably works 99% of the time, until something changes somewhere and now because its not filling out this or that field correctly, it starts breaking things.

Ask me how I know :grimacing:

Also,

DO NOT use the OData endpoints. Use the Custom Methods. It will save you a lot of trouble.

Okay so in REST API Swagger UI, I see the “Switch to custom methods lists” — do you have any advice on which custom method I should use?

I see the following which seem promising…

  1. InsertBOMMtl
  2. InsertMaterial
  3. InsertnewJobMtls
  4. Update/UpdateExt

On some of these custom methods, I do not see some fields like “QTY” for example; nor any of the UD fields we added to JobMtls that are important for our use-cases.

PS: I apologize if I seem to be relying on your help here. I am just finding out about custom methods; as well as the idea that OData REST API may not be the correct approach and would certainly like to do things the “correct” way.

Edit: Ahh I see that in @Jgrissom code snippet, they are using some of those custom methods (maybe stringing a few together); so I think that answers my question.

Thank you for sharing that resource.

I am working with typescript as well, so this will certainly come in handy.

I did try adding a MtlSeq as @genoah.martinelli suggested, but I am still getting errors.

I will review the code snippet you linked, try to implement it into my existing workflow, and will let you know how that works for me.

I am looking into the REST API custom methods now and will be migrating away from OData for any future REST API interactions.

Thank you again for your help so far.

Edit: @genoah.martinelli Ahh I see that in @Jgrissom code snippet, they are using some of those custom methods (maybe stringing a few together); so I think that answers my question about which is the best to use.

If you are using typescript, just copy the code block I pasted into its own file, update the epicorURL variable, and call the “jobAddMaterialCall”. For userData, you can remove the name,priviliges, and loaded fields as they arent needed for this.

If your company doesnt require a job to be un-engineered to add/remove materials, I would delete the changeJobHeadJobReleased and changeJobHeadJobEngineered lines at the bottom of the function, and inside the “finishingBits” constant.

ByPieces should be false, unless you are using attributes to calculate stuff, i.e. counts into lbs.

For the UD fields, just add them to the json body before “Update” is called