@stalfh233/omc-cli 0.5.14 → 0.5.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -38,7 +38,7 @@ export function dataSubmitDescription() {
38
38
  "Distinct from data.save: data.save persists as DRAFT; data.submit advances the record to COMPLETED (no workflow) or drives the workflow instance.",
39
39
  "**审批提交(workItemId+agree)时 `formData` 也必填,且其值会写回业务对象——勿把期望结果(如审批级别)预写进 formData,否则规则回写的真伪无法区分(2026-09-22 实测返工案例)**;只传身份参数会被拒 formData: Required。",
40
40
  `就地语义:${wire.submitInPlace};无流程 readback 必须确认 objectId 与传入 bizObjectId 相同;无 bizObjectId 的 workflowCode 新建单 → PROCESSING + workflowInstanceId,无流程 → 该行 COMPLETED。`,
41
- `既有草稿发起流程的唯一支持形状是两段式:先 data.save 带 workflowCode 暂存(${wire.workflowDraftSave}),再以返回的 workItemId 提交。`,
41
+ `既有草稿发起流程的唯一支持形状是两段式:先 data.save 带 workflowCode 暂存(${wire.workflowDraftSave}),再以返回的 workItemId 提交(issue#11 P6:带流程模型提交**必须**带 bizObjectId+workflowInstanceId+workItemId 三元——只带 bizObjectId 会答「流程实例无待办」、只缺 workItemId 会 50000;完整形状见 omc describe data.submit --example workflow-backed-data-submit,待办查询用 workitem.list)。`,
42
42
  `仅无 workflowCode/workItemId/workflowInstanceId 且无 bizObjectId 时保留 save→submit 便捷链(新建记录);只要存在 workItemId 或 workflowInstanceId 就 fail-closed 拒绝(blocker submit-biz-object-id-required),零 runtime/form/submit wire。`,
43
43
  `formType 自动判定:无任何 workflowCode/workItemId/workflowInstanceId 时**先按模型自动判定**——GET workflow/isworkflow?schemaCode=true 则用 workflow/getWorkflow 解析已发布流程码并**发起流程**(formType="${wire.formTypeWorkflow}",自动先 GET getReplayToken 注入 replayToken,否则 ${wire.replayTokenErrcode} 频繁重复提交);模型无流程 → "${wire.formTypeNoWorkflow}"(无流程提交,走 live 已证链 ${wire.submitChain};单发新建对象会 ${wire.typeCastErrcode},bizObject 勿带字符串 status)或 workItemId+workflowInstanceId(审批待办,agree **顶层**通过/驳回(approval 对象里放 agree 会被静默忽略并按通过处理),**必须带 bizObjectId 指向在途单据**,否则引擎 NPE ${wire.approvalNpeErrcode})→ "${wire.formTypeWorkflow}"。即「无流程直接提交、有流程发起流程」,不再把有流程模型的新建数据落成游离 sheet 行。`,
44
44
  "根因提示(环境相关,ticket 78 观测):**某些环境**里模型所在应用未发布时运行时提交稳定 50000,工具会反推并拒 app-not-published。该根因非普遍规律(见 data.save 的同条提示与 docs/issues/omc-cli-findings-2026-09-19.md B17)——遇 50000 先只读核对再判断。",
@@ -378,6 +378,65 @@ async function portalRequiredViolations(session, schemaCode, sheetCode, formData
378
378
  });
379
379
  return { requiredFields, missingRequired };
380
380
  }
381
+ /** 提交面:formData 里出现任何非空人员字段值即返回(issue#10:两种形状都可能 50000,可靠链路是提交省略)。 */
382
+ async function staffFieldsPresent(session, schemaCode, formData) {
383
+ const STAFF_TYPES = new Set([50, 51, 52]);
384
+ let rows;
385
+ try {
386
+ rows = wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode, isPublish: "true" } })).data);
387
+ }
388
+ catch {
389
+ return [];
390
+ }
391
+ const staffCodes = new Set();
392
+ for (const row of rows) {
393
+ const type = Number(row.type ?? row.propertyType);
394
+ const code = String(row.code ?? row.propertyCode ?? "");
395
+ if (code && STAFF_TYPES.has(type))
396
+ staffCodes.add(code);
397
+ }
398
+ const out = [];
399
+ for (const [code, value] of Object.entries(formData)) {
400
+ if (!staffCodes.has(code))
401
+ continue;
402
+ if (value === null || value === undefined || value === "")
403
+ continue;
404
+ if (Array.isArray(value) && value.length === 0)
405
+ continue;
406
+ out.push({ code, got: Array.isArray(value) ? `array[${value.length}]` : typeof value });
407
+ }
408
+ return out;
409
+ }
410
+ /** save 面(无 submit):人员字段只接受对象数组 [{id,type,name}]——字符串/字符串数组会 50000(issue#10 A)。 */
411
+ async function staffStringShapeViolations(session, schemaCode, formData) {
412
+ const STAFF_TYPES = new Set([50, 51, 52]);
413
+ let rows;
414
+ try {
415
+ rows = wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode, isPublish: "true" } })).data);
416
+ }
417
+ catch {
418
+ return [];
419
+ }
420
+ const staffCodes = new Set();
421
+ for (const row of rows) {
422
+ const type = Number(row.type ?? row.propertyType);
423
+ const code = String(row.code ?? row.propertyCode ?? "");
424
+ if (code && STAFF_TYPES.has(type))
425
+ staffCodes.add(code);
426
+ }
427
+ const out = [];
428
+ for (const [code, value] of Object.entries(formData)) {
429
+ if (!staffCodes.has(code))
430
+ continue;
431
+ if (value === null || value === undefined || value === "")
432
+ continue;
433
+ if (typeof value === "string")
434
+ out.push({ code, got: "string" });
435
+ else if (Array.isArray(value) && value.every((entry) => typeof entry === "string"))
436
+ out.push({ code, got: `string[${value.length}]` });
437
+ }
438
+ return out;
439
+ }
381
440
  /**
382
441
  * The platform dispatches each CHILD_TABLE(8) row on the form-save path by its
383
442
  * `rowStatus` (BizObjectUpdateModel.convertBizObjectOpItem): "Added" → insert,
@@ -980,6 +1039,7 @@ export function registerRuntimeDataLane(tools) {
980
1039
  formData: runtimeFormDataArg,
981
1040
  workflowCode: z.string().optional().describe("portal draft-save shape: create the row AND a DRAFT workflow instance + start workItem (finishStart=FALSE); submit later via data.submit with the returned workItemId. formData must NOT carry an id — in-place workflow start has no REST surface in 8.6.26"),
982
1041
  submit: z.boolean().optional().describe("true=保存后立即直提(无流程模型 DRAFT→COMPLETED,触发 Create 规则);与 workflowCode 互斥。**按业务约定正式数据优先直接 data.submit(无 id=新建并提交一调完成)——本参数是便捷别名,save 的定位是草稿/暂存**"),
1042
+ allowStaffOnSubmit: z.boolean().optional().describe("submit:true 时显式允许携带人员字段(issue#10:人员字段提交面两种形状都可能 50000,可靠链路是 save 落库后提交时省略;个别平台构建接受提交面携带,自担风险)"),
983
1043
  ...readbackProjectionArgs,
984
1044
  }, async (session, args) => {
985
1045
  // F-A (self-audit): the combo used to be silently ignored (the workflow
@@ -995,22 +1055,34 @@ export function registerRuntimeDataLane(tools) {
995
1055
  const rawSaveData = args.formData;
996
1056
  const saveIsUpdate = typeof rawSaveData.id === "string" && rawSaveData.id.trim().length > 0;
997
1057
  const formData = (await prepareRuntimeFormData(session, args.schemaCode, rawSaveData, { isUpdate: saveIsUpdate })).formData;
998
- // issue#9 C1(用户实测最高优先):data.submit 有 submit-person-field-shape 等
999
- // 写前 gate,data.save 同类形状错误裸奔——对象数组人员字段在 save 面直落
1000
- // 平台 50000(UserBizField.fillClientContent),同类失败重复 3 次还引发
1001
- // 「环境不可写」误判。save 面共用同一 gate(注意:save 草稿面接受对象数组
1002
- // ——只有 submit:true 直提路径才必须字符串 id,见 hazards 通道分叉条)。
1003
- if (args.submit === true) {
1004
- const staffShapedSave = await personFieldShapeViolations(session, args.schemaCode, formData).catch(() => []);
1005
- if (staffShapedSave.length > 0)
1058
+ // issue#9 C1 + issue#10 勘误:提交面(submit:true / data.submit)的人员字段
1059
+ // 两种已知形状都不安全——对象数组有历史 50000 证据(UserBizField.fillClientContent,
1060
+ // 本闸拦截),字符串 id 数组在 8.6.41 实测同样 50000(#10 D)。可靠链路只有一条:
1061
+ // data.save 人员字段用对象数组 [{id,type,name}] 落库 → 提交时**省略人员字段**(值已持久化)。
1062
+ if (args.submit === true && args.allowStaffOnSubmit !== true) {
1063
+ const staffOnSubmit = await staffFieldsPresent(session, args.schemaCode, formData).catch(() => []);
1064
+ if (staffOnSubmit.length > 0)
1006
1065
  throw new ToolRefusalError({
1007
1066
  status: "refused",
1008
1067
  blocker: "submit-person-field-shape",
1009
1068
  schemaCode: args.schemaCode,
1010
- fields: staffShapedSave,
1011
- message: `data.save submit:true 的人员字段形状错误:${staffShapedSave.map((entry) => `${entry.code}(${entry.got})`).join("、")}。` +
1012
- `提交面只接受字符串 id("<用户id>" 或 ["<id1>","<id2>"]);对象数组 [{id,type,name}] 会触发平台 UserBizField.fillClientContent 断言 50000。` +
1013
- `两条出路:①改成字符串 id 数组重提;②本字段留对象数组、去掉 submit:true 先存草稿,再 data.submit(两步法,草稿面接受对象数组)。`,
1069
+ fields: staffOnSubmit,
1070
+ message: `提交携带了人员字段:${staffOnSubmit.map((entry) => `${entry.code}(${entry.got})`).join("、")}。提交面的两种形状都不安全(对象数组触发 UserBizField.fillClientContent 50000;字符串 id 在 8.6.41 实测同样 50000——issue#10 复现矩阵)。` +
1071
+ `可靠链路:data.save 时人员字段用对象数组 [{"id":"<userId>","type":3,"name":"<名>"}](type:3=用户/1=部门)落库,提交(submit:true / data.submit)时**省略人员字段**——值已持久化,提交只推进流程。` +
1072
+ `确要在提交携带人员字段(个别平台构建接受),显式带 allowStaffOnSubmit:true 自担。`,
1073
+ });
1074
+ }
1075
+ // issue#10 A:save 面的字符串 id 才是错误形状(对象数组才是正解 B)——
1076
+ // 裸字符串/字符串数组在 save 面直落平台换一个无诊断的 50000,fail-closed。
1077
+ if (args.submit !== true) {
1078
+ const staffStringSave = await staffStringShapeViolations(session, args.schemaCode, formData).catch(() => []);
1079
+ if (staffStringSave.length > 0)
1080
+ throw new ToolRefusalError({
1081
+ status: "refused",
1082
+ blocker: "save-person-field-shape",
1083
+ schemaCode: args.schemaCode,
1084
+ fields: staffStringSave,
1085
+ message: `data.save 的人员字段用了字符串 id:${staffStringSave.map((entry) => `${entry.code}(${entry.got})`).join("、")}——save 面的正确形状是对象数组 [{"id":"<userId>","type":3,"name":"<名>"}](type:3=用户/1=部门;issue#10 实测矩阵:字符串 id 50000、对象数组成功)。读回值 [{id,type,name,…}] 可直接回写。`,
1014
1086
  });
1015
1087
  }
1016
1088
  if (typeof args.workflowCode === "string" && args.workflowCode.trim()) {
@@ -1313,6 +1385,7 @@ export function registerRuntimeDataLane(tools) {
1313
1385
  bizObjectId: z.string().optional().describe("existing record id; REQUIRED for workItem approval submits (formType=1 + workItemId) — the submit's bizObject must reference the in-flight record or the engine NPEs (DataChangeHandlerImpl.changeBizObject)"),
1314
1386
  agree: z.boolean().optional(),
1315
1387
  allowChildRowAppend: z.boolean().optional().describe("默认 false:审批提交(带 workItemId/workflowInstanceId)时 formData 含子表数组会被拒——平台按追加插行,每次提交重复一份。确实要追加子行时才置 true。"),
1388
+ allowStaffOnSubmit: z.boolean().optional().describe("默认 false:提交携带人员字段会被拒(issue#10:两种形状都可能 50000;可靠链路=save 落库后提交省略人员字段)。个别平台构建接受提交面携带时置 true 自担。"),
1316
1389
  approval: z.record(z.string(), z.unknown()).optional(),
1317
1390
  runMode: z.string().optional(),
1318
1391
  isSimulative: z.boolean().optional(),
@@ -1396,18 +1469,23 @@ export function registerRuntimeDataLane(tools) {
1396
1469
  // ——写成对象会让 UserBizField.fillClientContent 断言失败,平台只回 50000。
1397
1470
  // 读写不对称且 schema 无法表达(formData 是 additionalProperties),所以在
1398
1471
  // 写前按已发布字段类型做一次浅校验,把这类形状错误变成一条指名道姓的拒绝。
1399
- const staffShaped = await personFieldShapeViolations(session, args.schemaCode, formData).catch(() => []);
1400
- if (staffShaped.length > 0)
1401
- throw new ToolRefusalError({
1402
- status: "refused",
1403
- blocker: "submit-person-field-shape",
1404
- schemaCode: args.schemaCode,
1405
- fields: staffShaped,
1406
- message: `这些人员字段的取值是对象/数组:${staffShaped.map((entry) => `${entry.code}(${entry.got})`).join("、")}。` +
1407
- `人员字段**写入只接受字符串 id**(多个用逗号或数组的字符串 id)——读回来是 \`[{id,type,name,fullDepartment,…}]\`,` +
1408
- `把读回值原样写回会让平台 UserBizField.fillClientContent 断言失败、只回 50000 服务异常。` +
1409
- `写成 "\"<用户id>\"" 或 ["<id1>","<id2>"]。`,
1410
- });
1472
+ // issue#10(8.6.41 复现矩阵 + 本仓 1.1.25 复验):旧提示方向反了——
1473
+ // "写入只接受字符串 id"会让调用者按提示改成字符串数组,然后平台 50000
1474
+ // (submit 面 D)。提交面两种已知形状都不安全,可靠链路只有:save 时用
1475
+ // 对象数组落库 → 提交时省略人员字段(值已持久化)。
1476
+ if (args.allowStaffOnSubmit !== true) {
1477
+ const staffShaped = await staffFieldsPresent(session, args.schemaCode, formData).catch(() => []);
1478
+ if (staffShaped.length > 0)
1479
+ throw new ToolRefusalError({
1480
+ status: "refused",
1481
+ blocker: "submit-person-field-shape",
1482
+ schemaCode: args.schemaCode,
1483
+ fields: staffShaped,
1484
+ message: `提交携带了人员字段:${staffShaped.map((entry) => `${entry.code}(${entry.got})`).join("、")}。提交面的两种形状都可能 50000(对象数组触发 UserBizField.fillClientContent 断言;字符串 id 数组在 8.6.41/1.1.25 均实测 50000——issue#10 复现矩阵)。` +
1485
+ `可靠链路:data.save 时人员字段用对象数组 [{"id":"<userId>","type":3,"name":"<名>"}](type:3=用户/1=部门)落库,提交时**省略人员字段**——值已持久化,提交只推进流程。` +
1486
+ `确要在提交携带人员字段(个别平台构建接受),显式带 allowStaffOnSubmit:true 自担。`,
1487
+ });
1488
+ }
1411
1489
  const validation = await portalRequiredViolations(session, args.schemaCode, sheetCode, args.formData).catch(() => ({ requiredFields: [], missingRequired: [] }));
1412
1490
  // Directive: a workflow-backed model's submit must START the workflow, not
1413
1491
  // merely save a sheet row. When the caller names no workflow params, detect
@@ -286,6 +286,21 @@ function templateActivityCodes(template) {
286
286
  * template instead of believing the flow is running. WARN-level messages are
287
287
  * returned as evidence but do not block (live: `{Creator}` answers 401062 WARN).
288
288
  */
289
+ /**
290
+ * workflow.publish 高频拒绝码 → 可执行含义(issue#11 P1:401041/401053 码面文案
291
+ * 与触发场景错位,用户靠 5 组二分才定位)。全 93 码表见
292
+ * engine-api-guide/references/EngineErrCode.md(工作流段,自 ErrorCode 反编译)。
293
+ */
294
+ const WORKFLOW_PUBLISH_CODE_HINTS = Object.freeze({
295
+ "401026": "审批节点未绑定表单:sheetCode 置为模型默认表单槽位",
296
+ "401041": "多人节点参与者写成了 {Originator}——多人节点请用角色/函数表达式;发起/填写节点才可用 {Originator}",
297
+ "401053": "发起/填写节点的参与者类型不能是多人(MULTI_PARTICIPANT)",
298
+ "401047": "「上一活动参与者」函数只能接在单个用户活动节点之后",
299
+ "401048": "驳回-上一步:上一步必须是单用户活动节点",
300
+ "401052": "发起节点不允许配置「驳回到开始」",
301
+ "401057": "同意出口不能为空或 0/0%",
302
+ "401027": "存在孤立节点(未接入 START→END 链路)",
303
+ });
289
304
  function workflowValidationErrors(data) {
290
305
  if (!Array.isArray(data))
291
306
  return [];
@@ -306,7 +321,7 @@ function assertWorkflowWriteAccepted(performed, tool, workflowCode) {
306
321
  blocker: "workflow-publish-invalid",
307
322
  workflowCode,
308
323
  validationErrors: errors,
309
- message: `${tool}: 平台以 ValidateMessage[] 拒绝(errcode 0 但不落库):${errors.map((error) => `${error.errCode} ${error.errContent}`).join(";")}。常见根因:审批(PARTICIPANT)节点的 sheetCode 为空(=模型默认表单槽位,通常等于 schemaCode;401026 WORKFLOW_ACTIVITY_NO_SHEET——工具已自动回填,仍报则检查端点是否漏),或新增节点未接入链路(END 无入边)。修正模板后重试,不要反复 publish`,
324
+ message: `${tool}: 平台以 ValidateMessage[] 拒绝(errcode 0 但不落库):${errors.map((error) => `${error.errCode} ${error.errContent}${WORKFLOW_PUBLISH_CODE_HINTS[String(error.errCode)] ? `(${WORKFLOW_PUBLISH_CODE_HINTS[String(error.errCode)]})` : ""}`).join(";")}。常见根因:审批节点的 sheetCode 为空(401026,工具已自动回填);新增节点未接入链路(END 无入边);**多人节点参与者写成 {Originator}**(1.1.25 隔离矩阵实证:MULTI_PARTICIPANT+{Originator} 报 401041——该码文案"否决出口节点编码不能为空"在此场景具误导性,issue#11 P1;发起/填写节点须 SINGLE,多人报 401053)。修正模板后重试,不要反复 publish`,
310
325
  });
311
326
  }
312
327
  /**
@@ -39,14 +39,37 @@ export function projectJsonPath(value, path) {
39
39
  const base = projectJsonPath(value, before);
40
40
  if (base.missed)
41
41
  return base;
42
- const array = base.value;
43
- if (!Array.isArray(array))
44
- return { missed: true, at: before || "$", available: `该层不是数组(是 ${array === null ? "null" : typeof array});选择器 [${key}=${expected}] 需要数组` };
45
- const readKey = (entry) => (entry !== null && typeof entry === "object" ? String(entry[key] ?? "") : "");
46
- const index = array.findIndex((entry) => readKey(entry) === expected);
42
+ // issue#11 P7:工作流模板的 activities/rules 是「index → 条目」的**对象**,
43
+ // 不是数组;条目键名还常是 activityCode/nodeCode 而非 code。按键选择器因此
44
+ // 也要认 dict 容器与常见键别名,否则 workflow 节点只能靠下标猜。
45
+ const KEY_ALIASES = ["code", "activityCode", "nodeCode", "ruleCode", "propertyCode", "name"];
46
+ const readKey = (entry) => {
47
+ if (entry === null || typeof entry !== "object")
48
+ return "";
49
+ const record = entry;
50
+ const direct = record[key];
51
+ if (direct !== undefined && direct !== null && String(direct) !== "")
52
+ return String(direct);
53
+ for (const alias of KEY_ALIASES) {
54
+ const value = record[alias];
55
+ if (value !== undefined && value !== null && String(value) !== "")
56
+ return String(value);
57
+ }
58
+ return "";
59
+ };
60
+ const entries = Array.isArray(base.value)
61
+ ? base.value
62
+ : base.value !== null && typeof base.value === "object"
63
+ ? Object.values(base.value)
64
+ : [];
65
+ if (entries.length === 0 && !Array.isArray(base.value)) {
66
+ const shaped = base.value === null ? "null" : typeof base.value;
67
+ return { missed: true, at: before || "$", available: `该层不是数组/对象(是 ${shaped});选择器 [${key}=${expected}] 需要数组或 index→条目 对象` };
68
+ }
69
+ const index = entries.findIndex((entry) => readKey(entry) === expected);
47
70
  if (index < 0)
48
- return { missed: true, at: `${before}[${key}=${expected}]`, available: `数组长度 ${array.length};${key} 的候选值:${[...new Set(array.map(readKey))].slice(0, 12).join("、") || "(无)"}` };
49
- return after === "" ? { missed: false, value: array[index] } : projectJsonPath(array[index], after);
71
+ return { missed: true, at: `${before}[${key}=${expected}]`, available: `条目数 ${entries.length};候选值(${key}/别名):${[...new Set(entries.map(readKey).filter((value) => value !== ""))].slice(0, 16).join("、") || "(无)"}` };
72
+ return after === "" ? { missed: false, value: entries[index] } : projectJsonPath(entries[index], after);
50
73
  }
51
74
  const normalized = path
52
75
  .trim()
@@ -121,7 +144,7 @@ function argvValues(args, name) {
121
144
  return values;
122
145
  }
123
146
  /** Shared wording for every projection miss, so all three faces teach the same syntax. */
124
- const PATH_MISS_HINT = "路径未命中(这一层没有该段)。可用写法:点式 a.b、下标 a[0]、切片 a[0:6]、数组映射 items[].fields.x、按键选择 rules[code=Create](返回第一个匹配项)。available 是该层真实存在的键。";
147
+ const PATH_MISS_HINT = "路径未命中(这一层没有该段)。可用写法:点式 a.b、下标 a[0]、切片 a[0:6]、数组映射 items[].fields.x、按键选择 rules[code=Create] 或 data.activities[code=A1](数组与 index→条目对象都支持;键名可省 activityCode/nodeCode 等别名;返回第一个匹配项)。available 是该层真实存在的键。";
125
148
  /**
126
149
  * `call --extract/--extract-keys`: resolve paths inside the result's own
127
150
  * artifact (or inside the result object when the tool returned no artifact).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stalfh233/omc-cli",
3
- "version": "0.5.14",
3
+ "version": "0.5.15",
4
4
  "description": "CloudPivot 8.6 configuration capability CLI — governed, fail-closed, coverage-manifest bounded.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -52,9 +52,9 @@
52
52
  "access": "public"
53
53
  },
54
54
  "dependencies": {
55
- "@stalfh233/omc-fake-platform": "0.5.14",
55
+ "@stalfh233/omc-fake-platform": "0.5.15",
56
56
  "commander": "14.0.3",
57
- "omc-core": "0.5.14",
57
+ "omc-core": "0.5.15",
58
58
  "proper-lockfile": "4.1.2",
59
59
  "write-file-atomic": "6.0.0",
60
60
  "zod": "^3.23.8",
@@ -39,7 +39,7 @@ omc help omc-write-hazards
39
39
  - `propertyEmpty` 默认 `false`。模型层保持可空;必填、只读、可见性在表单和流程节点配置。
40
40
  - 无映射关联单选 `WORK_SHEET` 使用 `mappings: ""`,不要用 `"[]"`。`"[]"` 是非空字符串,设计器会显示“已设置”,但弹窗没有映射行。
41
41
  - 有映射时传非空 JSON 数组字符串,并在 draft/published 两面读回。
42
- - **人员字段形状随通道分叉(2026-11-02 用户实测)**:save/update 面=[{id,type,name}](type:用户 3/部门 1);**submit 面**(data.submit 或 save+submit:true)=**字符串 id 数组** ["<id>"]——对象数组+submit 触发平台 UserBizField.fillClientContent 断言 50000(OMC 预检会拦);一次提交人员字段被拒就改「先存草稿再 submit」两步。其余:单选(9)=id 串;多选(11)=分号串。
42
+ - **人员字段形状(issue#10,8.6.41+1.1.25 双验)**:save/update 只认对象数组 `[{id,type,name}]`(type:用户 3/部门 1)——字符串/字符串数组 50000;提交面两种形状都 50000。可靠链路=save 对象数组落库 → 提交**省略人员字段**。OMC 双闸已修正(提交带 allowStaffOnSubmit 放行)。单选(9)=id 串;多选(11)=分号串。
43
43
  - **`mappings` 由表单运行时在用户选择时套用**,服务端 `data.save` **不会**自动回填映射目标字段。API 侧要自己写被映射字段,否则读回为空——别把它误判成“映射配置失败”。
44
44
  - **关联映射的事实源在字段行 `options.mappings`,不在表单控件属性**(真机 2026-10-14 实证:改表单 attributes 里的映射会被平台按字段重算覆盖、回读不生效)。改映射走:`field.update` 按完整 options 提交(只改 mappings,其余键原值保留)→ `field.publish` → `form.draft`(**rebuild:false**,保留现有布局与在线JS)重存 → `form.publish` → 回读核对。**别用 cascadeRepublish 刷带布局/在线JS 的表单**:rebuild 会清空设计器公式(已做写前拒绝)。
45
45
  - **运行时写值的最小形状**(组合出错只会得到一个不透明的 `50000`/NPE,逐一最小化再合):单选关联(9) → id 字符串 `"<id>"`;**多选关联(11) → 分号串 `"<id1>;<id2>"`**;**人员/组织(50/51/60/61) → `[{"id":"<id>","type":3,"name":"<名>"}]`,`type` 必填且为整数**(缺/非整数 → `BizField.parseFromClient` 拆箱 NPE→50000;用户=3、部门=1);日期(3) → 字符串 `"2026-09-30 10:00:00"`;子表(8) → 行对象数组 `[{"<子列code>":<值>, ...}]`。先各写一条最小行通过,再组合。