On this page
Attach a Document to an Item, Assembly or Kit (Document Links)
Upload a file and add it to an item's Document Links tab through the API, the way the P21 client does it — including on assemblies and kits, on exactly the display areas you name (Assembly Maintenance, the sales-order pick ticket), or on all of them.
API: File handler REST (/api/filehandler) + Interactive (Item window) + OData read-back · Service: Item · Deep dive: 04 § Adding a Document Link in the Item Window · 15 § filehandler · Full schema: Item.json · Assembly.json
Prerequisites
- P21 credentials — the complete example below authenticates itself; nothing to install but
httpx(Python) or a barenet8.0-or-later console project (C#). - A user whose role can save the
Itemwindow. - The file to attach, on the machine that runs the example.
The Transaction API cannot add a link. Both the
Itemand theAssemblyservices list aDOCUMENT_LINK.document_linkdatawindow in their definitions, but a POST that setslink_namefails withColumn is disabled: link_nameon both. The grid is the display list (d_dw_document_link_display); links are added through the Add Link dialog, which only the Interactive API reaches.
Where an assembly's document lives
An assembly or kit has no document links of its own. It is an item, and its links are item links:
document_link column |
Value for an item link |
|---|---|
source_area_cd |
1314 (Item Maintenance) |
key1_cd / key1_value |
inv_mast_uid / the item's inv_mast_uid as a string |
row_status_flag |
704 active |
Each link has one document_link_area row per display area — the windows and printed forms where it shows. Assembly Maintenance only views links: its Document Links tab offers m_viewlink and no m_addlink.
- The header Document Links tab of Assembly Maintenance lists the assembly item's links that carry display area
1302(Assembly Maintenance). A link on the same item without1302does not appear there. - The component Document Links tab lists each component item's own links the same way.
Display areas that matter for assemblies and kits (code values, the same on every tenant):
display_area_cd |
Area |
|---|---|
1314 |
Item Maintenance (always selected — the window the link was made in) |
1445 |
Item Master Inquiry |
1302 |
Assembly Maintenance |
3453 |
Sales Order Pick Ticket |
1319 |
Order Entry/RMA |
1332 |
Shipping |
999 |
Packing List |
998 |
Order Acknowledgement |
1325 |
Production Order Entry/Processing |
2750 |
Production Order Form |
The Add Link dialog lists the rest in its Available Area list (26 areas on 26.1.5950.0). A document reaches a printed form only through that form's area — for the sales-order pick ticket, 3453 — and only when the link is Outside Use and the location sends outside-use documents to print. See Printing the document with a form.
Call sequence
- Upload the file into the folder the client's own Add Link dialog uses — the
item_maintenance_doc_link_upload_folder_pathsystem setting:POST /api/filehandler/file/path?fileName={name}&path={folder}with the raw bytes as the body. - Open the
Itemwindow, changeitem_idonTABPAGE_1, select theDOCUMENT_LINKtab. - Add Link:
POST /v2/toolswithToolName: "m_addlink"andDatawindowName: "document_link". It opens the tabless Document Link dialog (w_inventory_sheet). - Fill the dialog's
_dw_link:link_path,link_name,outside_use_flag,mandatory_flag. - Display areas: the dialog starts with Item Maintenance and Item Master Inquiry selected. To add one area, select its row in the
source(Available Areas) list with the web client'sPOST {ui_server}/ui/full/v2/data/select— 1-based row, read fresh fromGET /v2/data?id={dialog id}— then runcb_select.cb_selectallmoves every available area at once. cb_okcloses the dialog. Nothing is written yet.- Save the
Itemwindow (PUT /v2/data, body = the bare window id). Only the save writes thedocument_linkrow and itsdocument_link_arearows. A business rule can answer the save with a prompt (Status 3) — see Gotchas. - Read back
document_linkanddocument_link_areaover OData.
POST {ui_server}/api/ui/interactive/v2/tools
{ "WindowId": "<item window id>", "ToolName": "m_addlink", "DatawindowName": "document_link" }
PUT {ui_server}/api/ui/interactive/v2/change
{ "WindowId": "<dialog id>", "List": [
{ "TabName": null, "DatawindowName": "_dw_link", "FieldName": "link_path",
"Value": "\\\\fileserver\\share\\P21\\Files\\ItemMaint\\WIDGET-001 drawing.pdf" } ] }
POST {ui_server}/ui/full/v2/data/select
{ "WindowId": "<dialog id>", "DatawindowName": "source", "TabPageName": null,
"RowsToSelect": [3], "ActiveRowSpecification": "*" }
POST {ui_server}/api/ui/interactive/v2/tools
{ "WindowId": "<dialog id>", "ToolName": "cb_select" }
RowsToSelect holds the area's 1-based position in the current source list (3 here is an example). The answer is HTTP 200 with Result.SelectedRows and a buttonenabled event for cb_select.
Complete example
Attach a local file to an item — an assembly or kit works the same way. AREAS / Areas names the display areas to add to the dialog's defaults, spelled as the Available Areas list spells them; leave it empty for the defaults, or set "ALL" / { "ALL" } for every area. A second run finds the existing link and adds nothing.
"""Attach a file to an item's Document Links tab, the way the P21 client does it.
An assembly or kit is an item: its Assembly Maintenance Document Links tab and
its pick tickets use the same link, through the link's display areas.
1. Upload the file into Item Maintenance's document upload folder (file handler).
2. Add the link in the Item window's "Add Item Maintenance Link" dialog (Interactive),
on the dialog's default display areas plus the ones named in AREAS, or on every
area when AREAS is "ALL".
3. Save the Item window -- the dialog's OK alone writes nothing.
4. Read the link and its display areas back over OData.
"""
import re
import sys
from pathlib import Path
import httpx
# ---- EDIT THESE -----------------------------------------------------------
BASE_URL = "https://play.example.com" # your P21 server
USERNAME = "apiuser"
PASSWORD = "your-password"
VERIFY_SSL = False # True once you trust the cert chain
ITEM_ID = "WIDGET-001" # the item to attach to
FILE_PATH = "WIDGET-001 drawing.pdf" # the local file to attach
OUTSIDE_USE = "Y" # Outside Use: required to print with a form; "N" = internal only
AREAS = ["Assembly Maintenance", "Sales Order Pick Ticket"] # extra display areas, by name;
# [] = the dialog's defaults, "ALL" = every area
SAVE_PROMPT_ANSWER = "" # e.g. "cb_1" -- the button for a prompt the save raises
# ---------------------------------------------------------------------------
ITEM_MAINTENANCE_AREA = 1314 # document_link.source_area_cd for an item
UPLOAD_FOLDER_SETTING = "item_maintenance_doc_link_upload_folder_path"
def get_token(client: httpx.Client) -> str:
"""v2 token endpoint -- credentials go in the body, never in headers."""
r = client.post(
f"{BASE_URL}/api/security/token/v2",
json={"username": USERNAME, "password": PASSWORD},
headers={"Accept": "application/json"},
)
r.raise_for_status()
try:
return r.json()["AccessToken"]
except (ValueError, KeyError): # some middleware answers in XML
match = re.search(r"<AccessToken>([^<]+)</AccessToken>", r.text)
if not match:
raise ValueError(f"No AccessToken in response: {r.text[:200]}") from None
return match.group(1)
def get_ui_server(client: httpx.Client, token: str) -> str:
"""Transaction and Interactive calls go to the UI server, not BASE_URL."""
r = client.get(
f"{BASE_URL}/api/ui/router/v1/?urlType=external", # trailing slash avoids a 307
headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
)
r.raise_for_status()
try:
return r.json()["Url"].rstrip("/")
except (ValueError, KeyError):
match = re.search(r"<Url>([^<]+)</Url>", r.text)
if not match:
raise ValueError(f"No Url in router response: {r.text[:200]}") from None
return match.group(1).rstrip("/")
def odata(client: httpx.Client, headers: dict, table: str, select: str, where: str) -> list[dict]:
"""One OData table read."""
r = client.get(
f"{BASE_URL}/odataservice/odata/table/{table}",
params={"$select": select, "$filter": where},
headers=headers,
)
r.raise_for_status()
return r.json()["value"]
def opened_window(answer: dict) -> str:
"""The window id a tool opened (its windowopened event), or ""."""
for event in answer.get("Events") or []:
if event.get("Name") == "windowopened":
for pair in event.get("Data") or []:
if pair.get("Key") == "windowid":
return pair.get("Value") or ""
return ""
def checked(r: httpx.Response, what: str) -> dict:
"""The answer, refusing HTTP errors and P21's Status 2 (failure)."""
if r.status_code >= 400:
raise RuntimeError(f"{what}: HTTP {r.status_code} {r.text[:300]}")
body = r.json() if r.content else {}
if isinstance(body, dict) and body.get("Status") == 2:
raise RuntimeError(f"{what}: P21 refused it: {body.get('Messages')}")
return body if isinstance(body, dict) else {}
source = Path(FILE_PATH)
content = source.read_bytes()
with httpx.Client(verify=VERIFY_SSL, timeout=120, follow_redirects=True) as client:
token = get_token(client)
ui_server = get_ui_server(client, token)
auth = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
headers = {**auth, "Content-Type": "application/json"}
# The folder P21's own Add Link dialog uploads into, and the item's uid.
settings = odata(client, auth, "system_setting", "value", f"name eq '{UPLOAD_FOLDER_SETTING}'")
if not settings or not settings[0]["value"]:
sys.exit(f"System setting {UPLOAD_FOLDER_SETTING} is not set")
folder = settings[0]["value"].rstrip("\\")
quoted_id = ITEM_ID.replace("'", "''") # OData doubles a quote inside a string literal
items = odata(client, auth, "inv_mast", "inv_mast_uid", f"item_id eq '{quoted_id}'")
if not items:
sys.exit(f"{ITEM_ID} is not in P21")
uid = str(int(items[0]["inv_mast_uid"]))
link_path = f"{folder}\\{source.name}"
# A second run must not add a second link.
links_on_item = (
f"source_area_cd eq {ITEM_MAINTENANCE_AREA} and key1_cd eq 'inv_mast_uid' "
f"and key1_value eq '{uid}' and row_status_flag eq 704"
)
existing = odata(client, auth, "document_link", "document_link_uid,link_path", links_on_item)
if any(row["link_path"].lower() == link_path.lower() for row in existing):
sys.exit(f"{ITEM_ID} already links {link_path}")
# 1. Upload. The body is the raw file; path names the upload folder.
r = client.post(
f"{BASE_URL}/api/filehandler/file/path",
params={"fileName": source.name, "path": folder},
headers=auth,
content=content,
)
r.raise_for_status()
uploaded = r.json()
if uploaded.get("SizeInBytes") != len(content):
sys.exit(f"Upload landed {uploaded.get('SizeInBytes')} bytes of {len(content)}")
print(f"Uploaded {uploaded['FullPath']}")
# 2. The Item window, the item, its Document Links tab, Add Link.
session = checked(client.post(f"{ui_server}/api/ui/interactive/sessions/",
json={"ResponseWindowHandlingEnabled": True},
headers=headers), "session")
session_id = session.get("Id") or session.get("SessionId")
try:
window = checked(client.post(f"{ui_server}/api/ui/interactive/v2/window",
json={"SessionId": session_id, "ServiceName": "Item"},
headers=headers), "open Item")
window_id = window["WindowId"]
def tab(name: str) -> None:
checked(client.put(f"{ui_server}/api/ui/interactive/v2/tab",
json={"SessionId": session_id, "WindowId": window_id,
"TabName": name, "PageName": name},
headers=headers), f"tab {name}")
def change(target: str, field: str, value: str, *, tab_name=None, datawindow=None):
item = {"TabName": tab_name, "FieldName": field, "Value": value}
if datawindow:
item["DatawindowName"] = datawindow
return checked(client.put(f"{ui_server}/api/ui/interactive/v2/change",
json={"SessionId": session_id, "WindowId": target,
"List": [item]},
headers=headers), f"change {field}")
tab("TABPAGE_1")
change(window_id, "item_id", ITEM_ID, tab_name="TABPAGE_1")
tab("DOCUMENT_LINK")
answer = checked(client.post(f"{ui_server}/api/ui/interactive/v2/tools",
json={"SessionId": session_id, "WindowId": window_id,
"ToolName": "m_addlink",
"DatawindowName": "document_link"},
headers=headers), "Add Link")
dialog = opened_window(answer)
if not dialog:
raise RuntimeError(f"Add Link opened no dialog: {answer}")
# The dialog's own datawindow is _dw_link; it has no tabs.
for field, value in (("link_path", link_path), ("link_name", source.name),
("outside_use_flag", OUTSIDE_USE), ("mandatory_flag", "N")):
change(dialog, field, value, datawindow="_dw_link")
def area_names(datawindow: str) -> list[str]:
"""The dialog's Available ("source") or Selected ("target") areas, in order."""
r = client.get(f"{ui_server}/api/ui/interactive/v2/data",
params={"id": dialog}, headers=headers)
r.raise_for_status()
for dw in r.json():
if dw.get("Name") == datawindow:
column = dw["Columns"].index("code_description")
return [row[column] for row in dw.get("Data") or []]
return []
if AREAS == "ALL":
# Select All moves every Available Area to Selected Areas.
checked(client.post(f"{ui_server}/api/ui/interactive/v2/tools",
json={"SessionId": session_id, "WindowId": dialog,
"ToolName": "cb_selectall"},
headers=headers), "Select All")
else:
for area in AREAS:
if area in area_names("target"):
continue # one of the dialog's defaults
available = area_names("source") # re-read: the list shrinks as areas move
if area not in available:
raise RuntimeError(f"'{area}' is not an Available Area: {available}")
# Selecting the row is a web-client call: ui/full, no /api/ prefix,
# 1-based row. It is what enables the dialog's Select (cb_select) button.
checked(client.post(f"{ui_server}/ui/full/v2/data/select",
json={"WindowId": dialog, "DatawindowName": "source",
"TabPageName": None,
"RowsToSelect": [available.index(area) + 1],
"ActiveRowSpecification": "*"},
headers=headers), f"select {area}")
checked(client.post(f"{ui_server}/api/ui/interactive/v2/tools",
json={"SessionId": session_id, "WindowId": dialog,
"ToolName": "cb_select"},
headers=headers), f"Select {area}")
checked(client.post(f"{ui_server}/api/ui/interactive/v2/tools",
json={"SessionId": session_id, "WindowId": dialog,
"ToolName": "cb_ok"},
headers=headers), "OK")
# 3. Save the Item window: the body is the bare window id.
saved = checked(client.put(f"{ui_server}/api/ui/interactive/v2/data",
json=window_id, headers=headers), "save")
# A business rule on the Item window can stop the save with a prompt
# (Status 3). Name it and its buttons, and answer only when told to.
while saved.get("Status") == 3 and opened_window(saved):
prompt = opened_window(saved)
title = checked(client.get(f"{ui_server}/api/ui/interactive/v2/window",
params={"id": prompt}, headers=headers),
"prompt")["Definition"]["Title"]
buttons = {t["ToolName"]: t.get("Text") for t in client.get(
f"{ui_server}/api/ui/interactive/v2/tools", params={"windowId": prompt},
headers=headers).json() if t["ToolName"].startswith("cb_") and t.get("Text")}
if SAVE_PROMPT_ANSWER not in buttons:
raise RuntimeError(f"The save opened '{title}' with buttons {buttons}; "
"set SAVE_PROMPT_ANSWER to one of them")
print(f"Answered '{title}' with {buttons[SAVE_PROMPT_ANSWER]}")
saved = checked(client.post(f"{ui_server}/api/ui/interactive/v2/tools",
json={"SessionId": session_id, "WindowId": prompt,
"ToolName": SAVE_PROMPT_ANSWER},
headers=headers), "answer")
if saved.get("Status") != 1:
raise RuntimeError(f"The save did not finish (Status {saved.get('Status')}): "
f"{saved.get('Messages')}")
client.delete(f"{ui_server}/api/ui/interactive/v2/window",
params={"id": window_id}, headers=headers)
finally:
client.delete(f"{ui_server}/api/ui/interactive/sessions/", headers=headers)
# 4. Read it back: the link, and where P21 shows it.
link = next(row for row in odata(client, auth, "document_link",
"document_link_uid,link_name,link_path,outside_use_flag",
links_on_item)
if row["link_path"].lower() == link_path.lower())
areas = odata(client, auth, "document_link_area", "display_area_cd",
f"document_link_uid eq {int(link['document_link_uid'])} and row_status_flag eq 704")
print(f"Linked {link['link_name']} to {ITEM_ID} as document_link_uid "
f"{int(link['document_link_uid'])}, outside use {link['outside_use_flag']}, "
f"shown in areas {sorted(int(a['display_area_cd']) for a in areas)}")
// Attach a file to an item's Document Links tab, the way the P21 client does it.
// An assembly or kit is an item: its Assembly Maintenance Document Links tab and
// its pick tickets use the same link, through the link's display areas.
// 1. Upload the file into Item Maintenance's document upload folder (file handler).
// 2. Add the link in the Item window's "Add Item Maintenance Link" dialog (Interactive),
// on the dialog's default display areas plus the ones named in Areas, or on every
// area when Areas is { "ALL" }.
// 3. Save the Item window -- the dialog's OK alone writes nothing.
// 4. Read the link and its display areas back over OData.
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
// ---- EDIT THESE -----------------------------------------------------------
const string BaseUrl = "https://play.example.com"; // your P21 server
const string Username = "apiuser";
const string Password = "your-password";
const string ItemId = "WIDGET-001"; // the item to attach to
const string FilePath = "WIDGET-001 drawing.pdf"; // the local file to attach
const string OutsideUse = "Y"; // Outside Use: required to print with a form; "N" = internal only
string[] Areas = { "Assembly Maintenance", "Sales Order Pick Ticket" }; // extra display areas, by name;
// { } = the dialog's defaults, { "ALL" } = every area
const string SavePromptAnswer = ""; // e.g. "cb_1" -- the button for a prompt the save raises
// ---------------------------------------------------------------------------
const int ItemMaintenanceArea = 1314; // document_link.source_area_cd for an item
const string UploadFolderSetting = "item_maintenance_doc_link_upload_folder_path";
var handler = new HttpClientHandler
{
// Test tenants often present a self-signed cert. Delete this line in production.
ServerCertificateCustomValidationCallback =
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator,
};
using var client = new HttpClient(handler) { Timeout = TimeSpan.FromMinutes(2) };
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var token = await GetTokenAsync(client);
var uiServer = await GetUiServerAsync(client, token);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var fileName = Path.GetFileName(FilePath);
var content = await File.ReadAllBytesAsync(FilePath);
// The folder P21's own Add Link dialog uploads into, and the item's uid.
var settings = await ODataAsync("system_setting", "value", $"name eq '{UploadFolderSetting}'");
var folder = settings.Count == 0 ? null : settings[0].GetProperty("value").GetString();
if (string.IsNullOrEmpty(folder)) { Console.WriteLine($"System setting {UploadFolderSetting} is not set"); return; }
folder = folder.TrimEnd('\\');
var quotedId = ItemId.Replace("'", "''"); // OData doubles a quote inside a string literal
var items = await ODataAsync("inv_mast", "inv_mast_uid", $"item_id eq '{quotedId}'");
if (items.Count == 0) { Console.WriteLine($"{ItemId} is not in P21"); return; }
var uid = items[0].GetProperty("inv_mast_uid").GetDecimal().ToString("0");
var linkPath = $"{folder}\\{fileName}";
// A second run must not add a second link.
var linksOnItem = $"source_area_cd eq {ItemMaintenanceArea} and key1_cd eq 'inv_mast_uid' " +
$"and key1_value eq '{uid}' and row_status_flag eq 704";
var existing = await ODataAsync("document_link", "document_link_uid,link_path", linksOnItem);
if (existing.Any(r => string.Equals(r.GetProperty("link_path").GetString(), linkPath,
StringComparison.OrdinalIgnoreCase)))
{
Console.WriteLine($"{ItemId} already links {linkPath}");
return;
}
// 1. Upload. The body is the raw file; path names the upload folder.
var uploadUrl = $"{BaseUrl}/api/filehandler/file/path?fileName={Uri.EscapeDataString(fileName)}" +
$"&path={Uri.EscapeDataString(folder)}";
var upload = await client.PostAsync(uploadUrl, new ByteArrayContent(content));
upload.EnsureSuccessStatusCode();
using (var uploaded = JsonDocument.Parse(await upload.Content.ReadAsStringAsync()))
{
var size = uploaded.RootElement.GetProperty("SizeInBytes").GetInt64();
if (size != content.Length)
throw new InvalidOperationException($"Upload landed {size} bytes of {content.Length}");
Console.WriteLine($"Uploaded {uploaded.RootElement.GetProperty("FullPath").GetString()}");
}
// 2. The Item window, the item, its Document Links tab, Add Link.
var session = await SendAsync(HttpMethod.Post, $"{uiServer}/api/ui/interactive/sessions/",
new { ResponseWindowHandlingEnabled = true }, "session");
var sessionId = session.TryGetProperty("Id", out var id) ? id.GetString()
: session.GetProperty("SessionId").GetString();
try
{
var window = await SendAsync(HttpMethod.Post, $"{uiServer}/api/ui/interactive/v2/window",
new { SessionId = sessionId, ServiceName = "Item" }, "open Item");
var windowId = window.GetProperty("WindowId").GetString()!;
await TabAsync("TABPAGE_1");
await ChangeAsync(windowId, "item_id", ItemId, tabName: "TABPAGE_1");
await TabAsync("DOCUMENT_LINK");
var answer = await SendAsync(HttpMethod.Post, $"{uiServer}/api/ui/interactive/v2/tools",
new { SessionId = sessionId, WindowId = windowId, ToolName = "m_addlink",
DatawindowName = "document_link" }, "Add Link");
var dialog = OpenedWindow(answer)
?? throw new InvalidOperationException($"Add Link opened no dialog: {answer}");
// The dialog's own datawindow is _dw_link; it has no tabs.
foreach (var (field, value) in new[] {
("link_path", linkPath), ("link_name", fileName),
("outside_use_flag", OutsideUse), ("mandatory_flag", "N") })
await ChangeAsync(dialog, field, value, datawindow: "_dw_link");
if (Areas.SequenceEqual(new[] { "ALL" }))
// Select All moves every Available Area to Selected Areas.
await SendAsync(HttpMethod.Post, $"{uiServer}/api/ui/interactive/v2/tools",
new { SessionId = sessionId, WindowId = dialog, ToolName = "cb_selectall" }, "Select All");
else
foreach (var area in Areas)
{
if ((await AreaNamesAsync(dialog, "target")).Contains(area))
continue; // one of the dialog's defaults
var available = await AreaNamesAsync(dialog, "source"); // re-read: the list shrinks
if (!available.Contains(area))
throw new InvalidOperationException(
$"'{area}' is not an Available Area: {string.Join(", ", available)}");
// Selecting the row is a web-client call: ui/full, no /api/ prefix, 1-based
// row. It is what enables the dialog's Select (cb_select) button.
await SendAsync(HttpMethod.Post, $"{uiServer}/ui/full/v2/data/select",
new { WindowId = dialog, DatawindowName = "source", TabPageName = (string?)null,
RowsToSelect = new[] { available.IndexOf(area) + 1 },
ActiveRowSpecification = "*" }, $"select {area}");
await SendAsync(HttpMethod.Post, $"{uiServer}/api/ui/interactive/v2/tools",
new { SessionId = sessionId, WindowId = dialog, ToolName = "cb_select" },
$"Select {area}");
}
await SendAsync(HttpMethod.Post, $"{uiServer}/api/ui/interactive/v2/tools",
new { SessionId = sessionId, WindowId = dialog, ToolName = "cb_ok" }, "OK");
// 3. Save the Item window: the body is the bare window id.
var saved = await SendAsync(HttpMethod.Put, $"{uiServer}/api/ui/interactive/v2/data",
windowId, "save");
// A business rule on the Item window can stop the save with a prompt
// (Status 3). Name it and its buttons, and answer only when told to.
while (saved.GetProperty("Status").GetInt32() == 3 && OpenedWindow(saved) is string prompt)
{
using var promptWindow = JsonDocument.Parse(await client.GetStringAsync(
$"{uiServer}/api/ui/interactive/v2/window?id={prompt}"));
var title = promptWindow.RootElement.GetProperty("Definition").GetProperty("Title").GetString();
using var tools = JsonDocument.Parse(await client.GetStringAsync(
$"{uiServer}/api/ui/interactive/v2/tools?windowId={prompt}"));
var buttons = tools.RootElement.EnumerateArray()
.Where(t => t.GetProperty("ToolName").GetString()!.StartsWith("cb_") &&
!string.IsNullOrEmpty(t.GetProperty("Text").GetString()))
.ToDictionary(t => t.GetProperty("ToolName").GetString()!,
t => t.GetProperty("Text").GetString()!);
if (!buttons.ContainsKey(SavePromptAnswer))
throw new InvalidOperationException($"The save opened '{title}' with buttons " +
string.Join(", ", buttons.Select(b => $"{b.Key} = {b.Value}")) +
"; set SavePromptAnswer to one of them");
Console.WriteLine($"Answered '{title}' with {buttons[SavePromptAnswer]}");
saved = await SendAsync(HttpMethod.Post, $"{uiServer}/api/ui/interactive/v2/tools",
new { SessionId = sessionId, WindowId = prompt, ToolName = SavePromptAnswer }, "answer");
}
if (saved.GetProperty("Status").GetInt32() != 1)
throw new InvalidOperationException($"The save did not finish: {saved}");
await client.DeleteAsync($"{uiServer}/api/ui/interactive/v2/window?id={windowId}");
async Task TabAsync(string name) =>
await SendAsync(HttpMethod.Put, $"{uiServer}/api/ui/interactive/v2/tab",
new { SessionId = sessionId, WindowId = windowId, TabName = name, PageName = name },
$"tab {name}");
}
finally
{
await client.DeleteAsync($"{uiServer}/api/ui/interactive/sessions/");
}
// 4. Read it back: the link, and where P21 shows it.
var link = (await ODataAsync("document_link",
"document_link_uid,link_name,link_path,outside_use_flag", linksOnItem))
.First(r => string.Equals(r.GetProperty("link_path").GetString(), linkPath,
StringComparison.OrdinalIgnoreCase));
var linkUid = link.GetProperty("document_link_uid").GetDecimal().ToString("0");
var areas = (await ODataAsync("document_link_area", "display_area_cd",
$"document_link_uid eq {linkUid} and row_status_flag eq 704"))
.Select(a => (int)a.GetProperty("display_area_cd").GetDecimal()).Order();
Console.WriteLine($"Linked {link.GetProperty("link_name").GetString()} to {ItemId} as " +
$"document_link_uid {linkUid}, outside use " +
$"{link.GetProperty("outside_use_flag").GetString()}, shown in areas " +
$"[{string.Join(", ", areas)}]");
// --- helpers ---------------------------------------------------------------
// One OData table read.
async Task<List<JsonElement>> ODataAsync(string table, string select, string where)
{
var url = $"{BaseUrl}/odataservice/odata/table/{table}?$select={select}" +
$"&$filter={Uri.EscapeDataString(where)}";
var response = await client.GetAsync(url);
response.EnsureSuccessStatusCode();
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
return doc.RootElement.GetProperty("value").EnumerateArray().Select(e => e.Clone()).ToList();
}
// The dialog's Available ("source") or Selected ("target") areas, in order.
async Task<List<string>> AreaNamesAsync(string window, string datawindow)
{
using var doc = JsonDocument.Parse(await client.GetStringAsync(
$"{uiServer}/api/ui/interactive/v2/data?id={window}"));
foreach (var dw in doc.RootElement.EnumerateArray())
{
if (dw.GetProperty("Name").GetString() != datawindow) continue;
var column = dw.GetProperty("Columns").EnumerateArray()
.Select(c => c.GetString()).ToList().IndexOf("code_description");
return dw.TryGetProperty("Data", out var data) && data.ValueKind == JsonValueKind.Array
? data.EnumerateArray().Select(row => row[column].GetString()!).ToList()
: new List<string>();
}
return new List<string>();
}
// One Interactive call; refuses HTTP errors and P21's Status 2 (failure).
async Task<JsonElement> SendAsync(HttpMethod method, string url, object body, string what)
{
using var request = new HttpRequestMessage(method, url)
{
Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8,
"application/json"),
};
var response = await client.SendAsync(request);
var text = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
throw new InvalidOperationException($"{what}: HTTP {(int)response.StatusCode} {text}");
var answer = text.Length == 0 ? default : JsonDocument.Parse(text).RootElement.Clone();
if (answer.ValueKind == JsonValueKind.Object &&
answer.TryGetProperty("Status", out var status) && status.GetInt32() == 2)
throw new InvalidOperationException($"{what}: P21 refused it: {text}");
return answer;
}
// One field change, on a tab of the window or on a tabless dialog's datawindow.
async Task ChangeAsync(string target, string field, string value,
string? tabName = null, string? datawindow = null)
{
var item = new Dictionary<string, string?> { ["TabName"] = tabName, ["FieldName"] = field,
["Value"] = value };
if (datawindow is not null) item["DatawindowName"] = datawindow;
await SendAsync(HttpMethod.Put, $"{uiServer}/api/ui/interactive/v2/change",
new { SessionId = sessionId, WindowId = target, List = new[] { item } },
$"change {field}");
}
// The window id a tool opened (its windowopened event), or null.
static string? OpenedWindow(JsonElement answer)
{
if (!answer.TryGetProperty("Events", out var events)) return null;
foreach (var e in events.EnumerateArray())
if (e.GetProperty("Name").GetString() == "windowopened")
foreach (var pair in e.GetProperty("Data").EnumerateArray())
if (pair.GetProperty("Key").GetString() == "windowid")
return pair.GetProperty("Value").GetString();
return null;
}
// v2 token endpoint — credentials go in the body, never in headers.
static async Task<string> GetTokenAsync(HttpClient client)
{
var payload = JsonSerializer.Serialize(new { username = Username, password = Password });
var response = await client.PostAsync(
$"{BaseUrl}/api/security/token/v2",
new StringContent(payload, Encoding.UTF8, "application/json"));
response.EnsureSuccessStatusCode();
return ReadField(await response.Content.ReadAsStringAsync(), "AccessToken");
}
// Transaction and Interactive calls go to the UI server, not BaseUrl.
static async Task<string> GetUiServerAsync(HttpClient client, string token)
{
using var request = new HttpRequestMessage(
HttpMethod.Get, $"{BaseUrl}/api/ui/router/v1/?urlType=external");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await client.SendAsync(request);
response.EnsureSuccessStatusCode();
return ReadField(await response.Content.ReadAsStringAsync(), "Url").TrimEnd('/');
}
// Some middleware answers these two endpoints in XML even when asked for JSON.
static string ReadField(string payload, string field)
{
try
{
var value = JsonDocument.Parse(payload).RootElement.GetProperty(field).GetString();
if (!string.IsNullOrEmpty(value)) return value;
}
catch (Exception ex) when (ex is JsonException or KeyNotFoundException) { }
var match = System.Text.RegularExpressions.Regex.Match(payload, $"<{field}>([^<]+)</{field}>");
if (!match.Success)
throw new InvalidOperationException(
$"No {field} in response: {payload[..Math.Min(200, payload.Length)]}");
return match.Groups[1].Value;
}
Printing the document with a form
P21 never merges a linked document into a form's PDF. When a form prints, P21 renders the form by itself and names each linked file beside it. The client then prints each file as its own job, on the same printer.
What has to be true
| # | Condition | Where it is set |
|---|---|---|
| 1 | The link is on an item that appears on the form, and it has the form's display area: 3453 sales-order pick ticket, 999 packing list, 2750 production order form |
The link's areas (this recipe) |
| 2 | The link is Outside Use (document_link.outside_use_flag = Y) |
The link (OUTSIDE_USE / OutsideUse in the example) |
| 3 | The entity the form prints for sends outside-use documents to print. That is the location for the sales-order pick ticket and the production order form, and the ship-to for the packing list | document_link_entity_req: send_outside_use_docs_flag = Y and send_outside_use_print_flag = Y (Location Maintenance or Ship To: Send Outside Use Documents and its Print box) |
| 4 | Transmit Document Links (process_doclinks) is on when the form prints |
The print window. It defaults off in Pick Tickets, Reprint Pick Tickets, Production Order Report and Packing Lists. The Order service's print_tix sent the links without it |
A link that misses any one of these prints nothing. Neither the client nor the API reports an error, and the form prints alone.
- Outside Use off. A link with area
3453and Outside Use off didn't print, while the Outside Use links on the same ticket did. - Location off. With only the ship-to's requirements on, the pick ticket sent nothing. With only the location's on, the pick ticket sent its links, and the packing list sent nothing until the ship-to's were on.
- The other boxes in the panel. The requirements panel's own Print, Fax and Email boxes are for lot documentation. Setting
print_flagwithout Require Lot Documentation is refused with "Print, Email or Fax may not be checked unless Require Lot Documentation is set to Yes."
In the test the location was the sales, source and pick location at once, so which of the three counts was not separated.
Turn the location requirement on with the Transaction API Location service:
{ "Name": "Location", "UseCodeValues": false, "IgnoreDisabled": true,
"Transactions": [{ "Status": "New", "DataElements": [{
"Name": "TABPAGE_1.tp_1_dw_1", "Type": "Form", "Keys": ["id", "company_id"],
"Rows": [{ "Edits": [
{ "Name": "id", "Value": "10" }, { "Name": "company_id", "Value": "ACME" },
{ "Name": "send_outside_use_docs_flag", "Value": "Yes" },
{ "Name": "send_outside_use_print_flag", "Value": "ON" },
{ "Name": "outside_use_number_of_copies", "Value": "1" } ] }] }] }] }
send_outside_use_docs_flag takes the label Yes or No; ON is refused with "Invalid send_outside_use_docs_flag value: ON". Read the row back from document_link_entity_req (entity_type 1370 = location, entity_id = the location ID). The ShipTo service sets the same fields on TABPAGE_7.tp_7_dw_7 (entity_type 1328).
Assemblies and kits
- Kit. The pick ticket lists the kit and its components, and each item's own
3453links print: the kit item's and each component's. - Production-order assembly sold on a sales order. Only the assembly item's
3453link prints with the sales-order pick ticket; the components are not on it. The component links print with the Production Order Form (2750), together with the assembly's own2750link. The production pick ticket has no display area, and it printed no links.
What the client and the API return
Web client (ui/full, the Print, Print Immediately or Print Preview tool). The tool answers with a formdatastreamready event for the form and one filerequested event per linked file:
{ "Name": "filerequested", "EventData": {
"print": "\\\\fileserver\\share\\P21\\Files\\ItemMaint\\DRAWING.pdf", "link_name": "DRAWING.pdf" } }
The client renders the form with POST /api/document/generate/form, asks "Do you want to print the associated document links?", downloads each file with GET /api/filehandler/file/path?fileName=…&path=…, and sends it to the current printer. formdatastreamready.EventData.documentLinks repeats the list as ticket|name|path|….
Report API and Transaction print flags. POST /api/v2/process/pdfreport (m_reprintpicktickets with process_doclinks ON) and the Order service's print_tix return the form PDF alone. Each document carries the links in DocumentAssociations:
"DocumentAssociations": [
{ "AssociationType": 0, "DocumentId": "2840476", "Name": "DRAWING.pdf",
"Uri": "\\\\fileserver\\share\\P21\\Files\\ItemMaint\\DRAWING.pdf" } ]
DocumentId is the pick-ticket number. To print what the client prints, fetch each Uri through the file handler and send it after the form. With process_doclinks OFF on pdfreport, or with the location requirement off, the list is [].
Gotchas (verified on play 26.1.5950.0, 2026-10-17)
- Only the window save writes.
cb_okcloses the dialog and adds a grid row; closing the window without saving writes nothing. - Selecting one area is a ui/full call.
cb_selectmoves the selected rows of the dialog'ssourcelist, and the Interactive API has no call that selects one. The web client does it withPOST {ui_server}/ui/full/v2/data/select— no/api/prefix, the same bearer token, and it works on the dialog the Interactive session opened.RowsToSelectis 1-based and counts thesourcelist as it is now: eachcb_selectremoves the area from it, so read the list again before the next one. Found by capturing the web client's request. What does not work: PUT /api/ui/interactive/v2/rowsets the current row without selecting it;cb_selectthen fails withButton cb_select is not enabled.PUT /api/ui/interactive/v2/rows/selectedreturned an empty HTTP 500 for every body tried,RowsToSelectincluded;POSTto it is HTTP 405.- A business rule can stop the save. On a tenant with an Item rule, the save returns Status 3 and opens a rule-callback window — on play, Item Issues Detected with
cb_1= Yes, Proceed Anyway andcb_2= No, Cancel. The example names the prompt and its buttons and stops; setSAVE_PROMPT_ANSWER/SavePromptAnswerto answer it. Read the rule's message before you choose: 04 § Worked Example: Item Issues Detected. - The link path must be a real UNC path. A path that reaches the server with one leading backslash (
\fileserver\share\..., typically an escaping slip) is refused on thelink_pathchange: The path '…' is not valid - this path needs to point to a valid FileHandler location. With link-in-place allowed, a well-formed UNC path outside the upload folder is accepted as well — the upload is only needed when the file is not already on a share the server reads. - The upload does not overwrite.
POST /api/filehandler/file/pathto a file name that already exists in the folder returns an HTTP 500. The example checks for an existing link first; a file left by a run that failed after the upload has to be removed or renamed before the next run. - The document is never inside the ticket's PDF. It prints as a separate job. The API returns it as a
DocumentAssociationsentry beside the PDF, and only when the location's requirements send outside-use documents. Without that setting the entry list is empty. That is why the 10/17 test saw the ticket alone with Transmit Document Links on and off. See Printing the document with a form.
Verify
SELECT dl.document_link_uid, dl.link_name, dl.link_path, dl.outside_use_flag, a.display_area_cd
FROM document_link dl
JOIN document_link_area a ON a.document_link_uid = dl.document_link_uid AND a.row_status_flag = 704
WHERE dl.source_area_cd = 1314 AND dl.key1_cd = 'inv_mast_uid'
AND dl.key1_value = '<inv_mast_uid>' AND dl.row_status_flag = 704
ORDER BY dl.document_link_uid, a.display_area_cd
Over OData the example reads the same two tables (document_link, then document_link_area by document_link_uid). Expected after a run with AREAS = ["Assembly Maintenance", "Sales Order Pick Ticket"]: one link and four area rows — 1302, 1314, 1445, 3453. With the defaults, two rows (1314, 1445); with "ALL", 26 rows. Then open the assembly in Assembly Maintenance: the link is on the header Document Links tab only when 1302 is among its areas.
Credit: @mrwuss — Item-window flow and file-handler upload found and verified on play 26.1 (#200); display areas (single-area selection through
ui/full/v2/data/selectincluded), assemblies and kits, and the pick-ticket check verified on play 26.1.5950.0, 2026-10-17. Printing with a form (the location and Outside Use conditions,DocumentAssociations, and the client'sfilerequestedjobs) verified on play 26.1.5950.0, 2026-10-21, with a kit, a production-order assembly and two components through ship, invoice and the production order form (#222).