Production Order Runbook — Create to Invoice

Run a production order end-to-end: create it, log labor, print the pick ticket(s), confirm the pick, complete (receive) the order, and ship + invoice the linked sales order.

API: Transaction + Interactive · Service: ProductionOrder, m_picktickets, TimeEntry, ProductionOrderPicking, ProductionOrderProcessing, Order, Shipping · Deep dive: Production Order Lifecycle (End-to-End) · Full schema: ProductionOrder, ProductionOrderPicking, ProductionOrderProcessing, m_picktickets

This page is a checklist, not a script. Each stage says what to call, the fields that matter, and the trap that costs an afternoon — with a link to the deep section. The one complete example below covers the stage most people automate first: generating the pick ticket and reading back its status. For runnable code on the other stages, see record-labor-time, inventory-adjustment, order-with-assembly, create-sales-order, and generate-pick-ticket-pdf.

Prerequisites

Flag Y N
production_order_processing Production-order assembly (sales order line spawns/links a production order) Kit — explodes to components, no production order
auto_create_prod_order Auto-create + link the production order when the sales order saves Create and link manually
assembly_for_stock Build-to-stock (units dwell in inventory) Make-to-order

On a saved sales order, oe_line.assembly shows the outcome: B kit parent, N kit component, P production-order line, S build-to-stock line allocated from on-hand.

Runbook

Stage 1 — Create the production order

Two paths (deep dive):

The traps: on Path A, the customer's salesrep must be valid at the sales location (a DynaChange rule blocks the order otherwise) and the order date must differ from the required date. A production order commonly gets two pick tickets — parts, plus labor/intangibles when intangible components source from a paired non-stock location.

Stage 2 — Log labor BEFORE printing

Post labor via the TimeEntry service — record-labor-time recipe, deep dive. Labor becomes a charge component that must land on a pick ticket to be consumed at completion.

The trap: print first, add labor after (no reprint) → the labor is allocated but on no ticket (qty_on_pick_tickets = 0) and completion fails with "components have a quantity used of 0." Fix: reprint (generates a separate labor/intangibles ticket), then confirm the new ticket.

Stage 3 — Print the pick ticket and form

Two ways (deep dive):

The traps: print_pick_ticket emits only at the make location — components stocked elsewhere means the form comes back but no usable ticket; use m_picktickets at the stock location instead. A parts ticket only generates if the components have stock at the source location. Documents only return on a savable order — a bare reprint with nothing new errors "Save is not enabled." And never post an m_* report to /api/v2/transaction — it returns Succeeded and emits nothing.

Stage 4 — Confirm the pick (Interactive API ONLY)

Open the ProductionOrderPicking window, load the ticket on header TP_PRODPICKTICKETCONF.tp_prodpickticketconf (key prod_pick_ticket_number), set the Confirm Pick field row_status_flag to "Confirm", save. Confirm every ticket — parts and labor/intangibles. Deep dive.

The trap (the star of this page): posting row_status_flag = 'Confirm' through a bare POST /api/v2/transaction produces a shell confirm — the ticket status flips to 1962 and qty_confirmed gets stamped, but qty_applied stays 0 and no stock moves. The per-bin posted quantities live in a disabled TP_BIN grid that only the windowed (Interactive API or desktop) confirm populates. The real confirm applies the pick and moves components to the make location's WIP bin (inv_loc.primary_bin at prod_order_hdr.source_location_id; bin 0 when no primary is set).

Ticket status codes (prod_pick_ticket_hdr.row_status_flag): 702 Open · 1962 Confirmed · 1268 Completed. Detail rows: 704 normal, 1268 at completion.

Stage 5 — Complete the order (production receipt)

Drive the ProductionOrderProcessing window (deep dive):

  1. Select the line on TABPAGE_17.tp_17_dw_17, set qty_to_complete (partial completion is supported; qty_completed is a read-only rollup).
  2. On TABPAGE_ASSEMBLY_BIN.tabpage_assembly_bin set bin_cd (the finished item's inv_loc.primary_bin, often 0) and unit_quantity (= the completion quantity) — as two separate change calls.
  3. Optional per-component cost override: once qty_to_complete is set, TABPAGE_18.tp_18_dw_18 exposes an editable new_cost per component; it flows new_costPROP receipt cost → moving average → invoice COGS.
  4. Save → the assembly is received into inventory (inv_tran type PROP) and the ticketed components are consumed.

The trap: combining bin_cd and unit_quantity in one change call drops the quantity, and a later completion errors "sum of bin quantity ... does not equal quantity made."

Stage 6 — Ship and invoice the linked sales order

Deep dive:

  1. Print the sales order pick ticket: Order service transaction with print_tix = ON on TP_FRONTCOUNTER.tp_frontcounter (creates oe_pick_ticket).
  2. Ship + invoice: the Shipping service, header tp_1_dw_1 keyed by pick_ticket_no — retrieve and save. create_invoice defaults ON, so the save ships and invoices in one step. Partial shipments are supported.

The traps: the item needs a packaging code or the save fails. For contract pricing, leave unit_price unset and P21 auto-fills the job-contract price (binding oe_line.job_price_hdr_uid) — the contract must cover that specific ship-to.

Stage 7 — Fix quantity fallout (write-offs)

If on-hand ends up wrong, post an InventoryAdjustmentinventory-adjustment recipe, deep dive.

Cost model — before trusting COGS (deep dive): the PROP receipt cost = components + labor posted before completion. Shipment COGS is the moving average at ship time, not that order's receipt — while 2+ units sit in stock, a cost added to one smears across all (build-to-stock is exposed by design; make-to-order is largely immune). Labor posted after invoicing spawns a separate "Post Freight/Labor Prod. Order: NNNN" invoice ($0 price, ± COGS); the original invoice is untouched.

Complete example — print the pick ticket and read back its status

Generates the production pick ticket at the stock location with m_picktickets (creates the ticket record and returns the PDF), then reads the new ticket back with POST /api/v2/transaction/get to check its status. Prerequisite: the order's form must already be printed (prod_order_hdr.printed = 'Y') — run a ProductionOrder transaction with print_form = ON first. Auth comes from the shared helper.

import base64
import re

import httpx

BASE_URL = "https://play.p21server.com"
token, ui_server, headers = p21_auth(BASE_URL, "api_user", "api_pass")

PROD_ORDER = "1000123"   # production order number
STOCK_LOCATION = "10"    # where the components stock (NOT necessarily the make location)

# --- 1. Generate the pick ticket (creates the record + returns the PDF) ---
report = {
    "Name": "m_picktickets",
    "UseCodeValues": True,  # m_picktickets REQUIRES code values; False returns HTTP 500
    "Transactions": [{
        "Status": 0,        # reports use numeric 0, not "New"
        "DataElements": [{
            "Keys": [],
            "Type": 0,
            "Name": "TABPAGE_1.tp_1_dw_1",
            "Rows": [{"Edits": [
                {"Name": "create_pick_ticket_type", "Value": "P"},  # code "P" = Production Order
                {"Name": "beg_prod_order", "Value": PROD_ORDER},
                {"Name": "end_prod_order", "Value": PROD_ORDER},
                {"Name": "location_id", "Value": STOCK_LOCATION},
            ]}],
        }],
    }],
}

resp = httpx.post(
    f"{ui_server}/api/v2/process/pdfreport",  # NOT /api/v2/transaction (silent no-op there)
    headers=headers, json=report, verify=False, timeout=120,
)
resp.raise_for_status()
result = resp.json()

if not isinstance(result, list):  # errors come back as an envelope, not an array
    raise SystemExit(f"Report failed: {result.get('ErrorMessage')}")

doc = result[0]
if doc["ResponseStatus"]["StatusCode"] != "Success" or not doc.get("DocumentData"):
    raise SystemExit(f"Report failed: {doc['ResponseStatus'].get('Message')}")

file_name = doc["FileName"]  # e.g. "PPT123456 PRODUCTION_PICK_TICKET.pdf"
with open(file_name, "wb") as f:
    f.write(base64.b64decode(doc["DocumentData"]))
print(f"Saved {file_name}")

# --- 2. Read the new ticket back (ticket number comes from the FileName) ---
ticket_no = re.match(r"PPT(\d+)", file_name).group(1)
get_payload = {
    "ServiceName": "ProductionOrderPicking",
    "TransactionStates": [{
        "DataElementName": "TP_PRODPICKTICKETCONF.tp_prodpickticketconf",
        "Keys": [{"Name": "prod_pick_ticket_number", "Value": ticket_no}],
    }],
}
resp = httpx.post(
    f"{ui_server}/api/v2/transaction/get",
    headers=headers, json=get_payload, verify=False,
)
resp.raise_for_status()

for txn in resp.json().get("Transactions", []):
    for de in txn.get("DataElements", []):
        for row in de.get("Rows", []):
            fields = {e["Name"]: e["Value"] for e in row.get("Edits", [])}
            if "row_status_flag" in fields:
                # 702 = Open, 1962 = Confirmed, 1268 = Completed
                print(f"Ticket {fields.get('prod_pick_ticket_number')} "
                      f"for prod order {fields.get('prod_order_number')}: "
                      f"status {fields.get('row_status_flag')}")
using System.Text.RegularExpressions;

var session = await P21Session.CreateAsync(
    "https://play.p21server.com", "api_user", "api_pass");

const string prodOrder = "1000123";   // production order number
const string stockLocation = "10";    // where the components stock

// --- 1. Generate the pick ticket (creates the record + returns the PDF) ---
var report = new JObject
{
    ["Name"] = "m_picktickets",
    ["UseCodeValues"] = true,  // m_picktickets REQUIRES code values; false returns HTTP 500
    ["Transactions"] = new JArray
    {
        new JObject
        {
            ["Status"] = 0,    // reports use numeric 0, not "New"
            ["DataElements"] = new JArray
            {
                new JObject
                {
                    ["Keys"] = new JArray(),
                    ["Type"] = 0,
                    ["Name"] = "TABPAGE_1.tp_1_dw_1",
                    ["Rows"] = new JArray
                    {
                        new JObject
                        {
                            ["Edits"] = new JArray
                            {
                                new JObject { ["Name"] = "create_pick_ticket_type", ["Value"] = "P" },
                                new JObject { ["Name"] = "beg_prod_order", ["Value"] = prodOrder },
                                new JObject { ["Name"] = "end_prod_order", ["Value"] = prodOrder },
                                new JObject { ["Name"] = "location_id", ["Value"] = stockLocation }
                            }
                        }
                    }
                }
            }
        }
    }
};

// NOT /api/v2/transaction (silent no-op there)
var reportResp = await session.Http.PostAsync(
    $"{session.UiServer}/api/v2/process/pdfreport",
    new StringContent(report.ToString(), Encoding.UTF8, "application/json"));
reportResp.EnsureSuccessStatusCode();
var reportBody = JToken.Parse(await reportResp.Content.ReadAsStringAsync());

if (reportBody.Type != JTokenType.Array)  // errors come back as an envelope, not an array
    throw new Exception($"Report failed: {reportBody["ErrorMessage"]}");

var doc = (JObject)((JArray)reportBody)[0];
if (doc["ResponseStatus"]?["StatusCode"]?.ToString() != "Success")
    throw new Exception($"Report failed: {doc["ResponseStatus"]?["Message"]}");

var fileName = doc["FileName"]!.ToString(); // e.g. "PPT123456 PRODUCTION_PICK_TICKET.pdf"
await File.WriteAllBytesAsync(fileName, Convert.FromBase64String(doc["DocumentData"]!.ToString()));
Console.WriteLine($"Saved {fileName}");

// --- 2. Read the new ticket back (ticket number comes from the FileName) ---
var ticketNo = Regex.Match(fileName, @"PPT(\d+)").Groups[1].Value;
var getPayload = new JObject
{
    ["ServiceName"] = "ProductionOrderPicking",
    ["TransactionStates"] = new JArray
    {
        new JObject
        {
            ["DataElementName"] = "TP_PRODPICKTICKETCONF.tp_prodpickticketconf",
            ["Keys"] = new JArray
            {
                new JObject { ["Name"] = "prod_pick_ticket_number", ["Value"] = ticketNo }
            }
        }
    }
};

var getResp = await session.Http.PostAsync(
    $"{session.UiServer}/api/v2/transaction/get",
    new StringContent(getPayload.ToString(), Encoding.UTF8, "application/json"));
getResp.EnsureSuccessStatusCode();
var getResult = JObject.Parse(await getResp.Content.ReadAsStringAsync());

foreach (var txn in getResult["Transactions"] as JArray ?? new JArray())
foreach (var de in txn["DataElements"] as JArray ?? new JArray())
foreach (var row in de["Rows"] as JArray ?? new JArray())
{
    var fields = new Dictionary<string, string>();
    foreach (var edit in row["Edits"] as JArray ?? new JArray())
        fields[edit["Name"]!.ToString()] = edit["Value"]?.ToString() ?? "";
    if (fields.ContainsKey("row_status_flag"))
        // 702 = Open, 1962 = Confirmed, 1268 = Completed
        Console.WriteLine($"Ticket {fields.GetValueOrDefault("prod_pick_ticket_number")} " +
                          $"for prod order {fields.GetValueOrDefault("prod_order_number")}: " +
                          $"status {fields.GetValueOrDefault("row_status_flag")}");
}

Payload file: JSON — validator-verified. (XML untested for the pdfreport endpoint.)

End-to-end files (runnable from the repo with a .env, dry-run by default): examples/python/recipes/production_order_runbook.py · examples/csharp/Recipes/ProductionOrderRunbook.cs. The snippet above is self-contained; the files use the repo's shared common / P21Examples.Common helpers like every other example.

Gotchas

Verify

After each stage, read back — don't trust the HTTP status:

Stage Check
Print Pick ticket exists and is 702 Open (example above); prod_order_hdr.printed = 'Y'
Confirm Ticket row_status_flag = 1962 and quantities applied (qty_applied > 0) — a 1962 with qty_applied = 0 is a shell confirm
Complete Ticket/detail rows at 1268; assembly receipt posted as inv_tran type PROP
Ship + invoice Invoice created by the Shipping save; shipment posts inv_tran type WO

Credit: Alex Westemeier