@stalfh233/omc-cli 0.4.96 → 0.4.97
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.
|
@@ -324,12 +324,61 @@ export function registerOrganizationLane(tools) {
|
|
|
324
324
|
const after = (await wireCall(session, { path: ENDPOINTS.organizationDepartmentGet, query: { ...orgQuery(session), deptId: args.deptId } })).data;
|
|
325
325
|
return { performed: { errcode: 0 }, readback: { deptId: args.deptId, name: after?.name ?? null, parentId: after?.parentId ?? null, managerId: after?.managerId ?? null, enabled: after?.enabled ?? null } };
|
|
326
326
|
});
|
|
327
|
-
writeTool(tools, "org.department.delete", LANE, SCOPES.deptDelete, "删除部门(`GET organization/department/strike_out?id=`)。平台会拒绝:有子部门 → 200009「部门下面存在子部门,不能删除!」;有用户 → 200002「部门下面存在用户,不能删除!」。工具先计数并在回读里给出,仍由平台裁决。", {
|
|
327
|
+
writeTool(tools, "org.department.delete", LANE, SCOPES.deptDelete, "删除部门(`GET organization/department/strike_out?id=`)。平台会拒绝:有子部门 → 200009「部门下面存在子部门,不能删除!」;有用户 → 200002「部门下面存在用户,不能删除!」。工具先计数并在回读里给出,仍由平台裁决。", {
|
|
328
|
+
deptId: z.string().min(1),
|
|
329
|
+
force: z.boolean().optional().describe("true = 先逐个删除子部门(仅当子部门无用户)再删本部门;有用户仍由平台裁决"),
|
|
330
|
+
transferUsersTo: z
|
|
331
|
+
.string()
|
|
332
|
+
.min(1)
|
|
333
|
+
.optional()
|
|
334
|
+
.describe("有员工时的转出目标部门 id:给了它就先把本部门(及 force 时的子树各部门)的员工逐人 transferDepartment 到该部门再删;不给则带人即拒绝。目标必须是现存部门,且不能是被删子树的一部分"),
|
|
335
|
+
}, async (session, args) => {
|
|
328
336
|
const users = wireRows((await wireCall(session, { path: ENDPOINTS.organizationDepartmentUsers, query: { ...orgQuery(session), deptId: args.deptId } })).data);
|
|
329
337
|
// Pre-check children: the platform's 200009 ("部门下面存在子部门") is
|
|
330
338
|
// accurate but sends the caller hunting for ids. Name them and hand over
|
|
331
339
|
// the child-delete command (found during r15 org cleanup).
|
|
332
340
|
const walked = await collectOrgDepartments(session);
|
|
341
|
+
// transferUsersTo (opt-in member evacuation): the target must be a real
|
|
342
|
+
// department and must NOT be inside the subtree being deleted — moving
|
|
343
|
+
// people into a dept that is about to be deleted would lose them.
|
|
344
|
+
const transferTarget = typeof args.transferUsersTo === "string" ? args.transferUsersTo.trim() : "";
|
|
345
|
+
let transferUsers;
|
|
346
|
+
if (transferTarget) {
|
|
347
|
+
if (!walked.rows.some((row) => row.id === transferTarget))
|
|
348
|
+
throw new ToolRefusalError({
|
|
349
|
+
status: "refused", blocker: "department-transfer-target-absent", deptId: String(args.deptId), transferUsersTo: transferTarget,
|
|
350
|
+
message: `transferUsersTo 的目标部门 ${transferTarget} 不存在(org.department.list 里没有)。先确认部门 id,或先创建目标部门。`,
|
|
351
|
+
});
|
|
352
|
+
const subtreeIds = new Set([String(args.deptId)]);
|
|
353
|
+
let grew = true;
|
|
354
|
+
while (grew) {
|
|
355
|
+
grew = false;
|
|
356
|
+
for (const row of walked.rows)
|
|
357
|
+
if (subtreeIds.has(row.parentId) && !subtreeIds.has(row.id)) {
|
|
358
|
+
subtreeIds.add(row.id);
|
|
359
|
+
grew = true;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (subtreeIds.has(transferTarget))
|
|
363
|
+
throw new ToolRefusalError({
|
|
364
|
+
status: "refused", blocker: "department-transfer-into-subtree", deptId: String(args.deptId), transferUsersTo: transferTarget,
|
|
365
|
+
message: `transferUsersTo=${transferTarget} 在被删除的子树里——把人转进一个即将被删的部门会丢成员。换一个子树外的部门。`,
|
|
366
|
+
});
|
|
367
|
+
transferUsers = async (fromDeptId, deptUsers) => {
|
|
368
|
+
let moved = 0;
|
|
369
|
+
for (const member of deptUsers) {
|
|
370
|
+
const userId = String(member.id ?? member.userId ?? "");
|
|
371
|
+
if (!userId)
|
|
372
|
+
continue;
|
|
373
|
+
await wireCall(session, {
|
|
374
|
+
path: ENDPOINTS.organizationUserDeptTransfer, method: "POST", query: orgQuery(session),
|
|
375
|
+
body: { userIds: [userId], sourceDept: fromDeptId, targetDept: transferTarget },
|
|
376
|
+
});
|
|
377
|
+
moved += 1;
|
|
378
|
+
}
|
|
379
|
+
return moved;
|
|
380
|
+
};
|
|
381
|
+
}
|
|
333
382
|
const children = walked.rows
|
|
334
383
|
.filter((row) => row.parentId === String(args.deptId))
|
|
335
384
|
.map((row) => ({ id: row.id, name: row.name }))
|
|
@@ -363,6 +412,11 @@ export function registerOrganizationLane(tools) {
|
|
|
363
412
|
deletedChildren = ordered.length;
|
|
364
413
|
for (const child of ordered) {
|
|
365
414
|
try {
|
|
415
|
+
if (transferUsers) {
|
|
416
|
+
const childUsers = wireRows((await wireCall(session, { path: ENDPOINTS.organizationDepartmentUsers, query: { ...orgQuery(session), deptId: child.id } })).data);
|
|
417
|
+
if (childUsers.length > 0)
|
|
418
|
+
await transferUsers(child.id, childUsers);
|
|
419
|
+
}
|
|
366
420
|
await wireCall(session, { path: ENDPOINTS.organizationDepartmentDelete, query: { ...orgQuery(session), id: child.id } });
|
|
367
421
|
}
|
|
368
422
|
catch (error) {
|
|
@@ -372,12 +426,16 @@ export function registerOrganizationLane(tools) {
|
|
|
372
426
|
status: "refused", blocker: usersPending ? "department-has-users" : "department-delete-failed",
|
|
373
427
|
deptId: String(args.deptId), blockedChild: child,
|
|
374
428
|
nextCommand: `omc call org.department.users --args '${JSON.stringify({ deptId: child.id })}'`,
|
|
375
|
-
message: `force 清理到子部门 ${child.name}(${child.id}) 时失败:${message}。${usersPending ? "
|
|
429
|
+
message: `force 清理到子部门 ${child.name}(${child.id}) 时失败:${message}。${usersPending ? "该部门下还有员工——先转出再删(或传 transferUsersTo 让工具搬)。" : "平台拒绝删除该子部门。"}`,
|
|
376
430
|
});
|
|
377
431
|
}
|
|
378
432
|
}
|
|
379
433
|
}
|
|
380
|
-
|
|
434
|
+
let transferredUsers = 0;
|
|
435
|
+
if (users.length > 0 && transferUsers) {
|
|
436
|
+
transferredUsers = await transferUsers(String(args.deptId), users);
|
|
437
|
+
}
|
|
438
|
+
if (users.length > 0 && !transferUsers) {
|
|
381
439
|
// Name every member and hand over a runnable transfer for the first one:
|
|
382
440
|
// "先看人再转出" left the caller to assemble the transfer args (item 9).
|
|
383
441
|
const members = users.map((row) => ({ id: row.id ?? row.userId ?? null, name: row.name ?? null, username: row.username ?? null }));
|
|
@@ -389,12 +447,20 @@ export function registerOrganizationLane(tools) {
|
|
|
389
447
|
? `omc call org.user.transferDepartment --args '${JSON.stringify({ userId: first.id, departmentId: parentId })}'`
|
|
390
448
|
: `omc call org.department.users --args '${JSON.stringify({ deptId: String(args.deptId) })}'`,
|
|
391
449
|
message: `部门 ${args.deptId} 下还有 ${users.length} 名员工,平台拒绝删除(200002)。成员:${members.map((member) => `${member.name ?? "?"}(${member.id ?? "?"})`).join("、")}。` +
|
|
392
|
-
`平台不允许删带人的部门,也不提供"随部门级联转出"
|
|
393
|
-
|
|
450
|
+
`平台不允许删带人的部门,也不提供"随部门级联转出"。两个选择:` +
|
|
451
|
+
`① 逐个 org.user.transferDepartment 转到 ${parentId ? `父部门 ${parentId}` : "目标部门"} 后再删;` +
|
|
452
|
+
`② 本命令直接带 transferUsersTo:"<目标部门id>",工具会先把人逐人转出(含 force 时的子树各部门)再删。` +
|
|
453
|
+
`(默认不搬人——搬人是组织语义决策,不替你选目标部门。)`,
|
|
394
454
|
});
|
|
395
455
|
}
|
|
396
456
|
await wireCall(session, { path: ENDPOINTS.organizationDepartmentDelete, query: { ...orgQuery(session), id: args.deptId } });
|
|
397
|
-
return {
|
|
457
|
+
return {
|
|
458
|
+
performed: { errcode: 0 },
|
|
459
|
+
readback: {
|
|
460
|
+
deptId: args.deptId, deleted: true, usersBeforeDelete: users.length, deletedChildren,
|
|
461
|
+
...(transferredUsers > 0 ? { transferredUsers, transferredTo: transferTarget } : {}),
|
|
462
|
+
},
|
|
463
|
+
};
|
|
398
464
|
});
|
|
399
465
|
tools.push(defineTool({
|
|
400
466
|
name: "org.department.users",
|
|
@@ -410,20 +410,46 @@ function collectChildArrayPaths(value, childCodes, prefix = "") {
|
|
|
410
410
|
}
|
|
411
411
|
return found;
|
|
412
412
|
}
|
|
413
|
-
|
|
414
|
-
|
|
413
|
+
/**
|
|
414
|
+
* One published field.list read, reused by the child-row stamping AND the
|
|
415
|
+
* value-shape gates below, so the gates add zero extra wire calls.
|
|
416
|
+
*/
|
|
417
|
+
async function loadPublishedFieldMeta(session, schemaCode) {
|
|
415
418
|
try {
|
|
416
419
|
const rows = wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode, isPublish: "true" } })).data);
|
|
420
|
+
const childCodes = [];
|
|
421
|
+
const meta = new Map();
|
|
417
422
|
for (const row of rows) {
|
|
418
|
-
|
|
419
|
-
|
|
423
|
+
const code = String(row.code ?? row.propertyCode ?? "");
|
|
424
|
+
if (!code)
|
|
425
|
+
continue;
|
|
426
|
+
const type = Number(row.propertyType ?? row.type);
|
|
427
|
+
let format;
|
|
428
|
+
if (typeof row.options === "string" && row.options) {
|
|
429
|
+
try {
|
|
430
|
+
const parsed = JSON.parse(row.options);
|
|
431
|
+
if (typeof parsed.format === "string")
|
|
432
|
+
format = parsed.format;
|
|
433
|
+
}
|
|
434
|
+
catch {
|
|
435
|
+
// unparsable options: shape gates for this field are skipped
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
meta.set(code, { type, ...(format !== undefined ? { format } : {}) });
|
|
439
|
+
if (type === CHILD_TABLE_PROPERTY_TYPE)
|
|
440
|
+
childCodes.push(code);
|
|
420
441
|
}
|
|
442
|
+
return { childCodes, meta };
|
|
421
443
|
}
|
|
422
444
|
catch {
|
|
423
|
-
return
|
|
445
|
+
return null;
|
|
424
446
|
}
|
|
425
|
-
|
|
426
|
-
|
|
447
|
+
}
|
|
448
|
+
async function stampChildRowStatuses(session, schemaCode, formData, preloaded) {
|
|
449
|
+
const loaded = preloaded !== undefined ? preloaded : await loadPublishedFieldMeta(session, schemaCode);
|
|
450
|
+
if (!loaded || loaded.childCodes.length === 0)
|
|
451
|
+
return { formData, childCodes: loaded?.childCodes ?? [], meta: loaded?.meta ?? new Map() };
|
|
452
|
+
const childCodes = new Set(loaded.childCodes);
|
|
427
453
|
let changed = false;
|
|
428
454
|
const next = { ...formData };
|
|
429
455
|
for (const code of childCodes) {
|
|
@@ -441,7 +467,136 @@ async function stampChildRowStatuses(session, schemaCode, formData) {
|
|
|
441
467
|
return { ...row, rowStatus: hasId ? "Modified" : "Added" };
|
|
442
468
|
});
|
|
443
469
|
}
|
|
444
|
-
return { formData: changed ? next : formData, childCodes: [...childCodes] };
|
|
470
|
+
return { formData: changed ? next : formData, childCodes: [...childCodes], meta: loaded.meta };
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* The one write-path preflight shared by data.save / data.update / data.submit:
|
|
474
|
+
* load the published field metadata ONCE, refuse the proven bad value shapes
|
|
475
|
+
* (date/relation/child-row-id) BEFORE any mutation, then stamp rowStatus.
|
|
476
|
+
*/
|
|
477
|
+
async function prepareRuntimeFormData(session, schemaCode, formData, opts) {
|
|
478
|
+
const loaded = await loadPublishedFieldMeta(session, schemaCode);
|
|
479
|
+
if (loaded)
|
|
480
|
+
assertRuntimeFieldValueShapes(loaded.meta, formData, loaded.childCodes, { schemaCode, isUpdate: opts.isUpdate });
|
|
481
|
+
const stamped = await stampChildRowStatuses(session, schemaCode, formData, loaded);
|
|
482
|
+
return { formData: stamped.formData, childCodes: stamped.childCodes };
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Translate a platform DATE options.format into a validator regex. The format
|
|
486
|
+
* enum is UPPERCASE (`YYYY-MM-DD HH:mm:ss`), tokenized longest-first; literals
|
|
487
|
+
* (`-`, `/`, `:`, space, 年/月/日) are escaped. Unknown formats return null —
|
|
488
|
+
* the gate then stays silent rather than guessing.
|
|
489
|
+
*/
|
|
490
|
+
function dateFormatRegex(format) {
|
|
491
|
+
const KNOWN = new Set([
|
|
492
|
+
"YYYY", "YYYY-MM", "YYYY/MM", "YYYY-MM-DD", "YYYY/MM/DD",
|
|
493
|
+
"YYYY-MM-DD HH:mm", "YYYY/MM/DD HH:mm", "YYYY-MM-DD HH:mm:ss", "YYYY/MM/DD HH:mm:ss",
|
|
494
|
+
"YYYY-MM-DD CN-APM", "YYYY/MM/DD CN-APM", "YYYY-MM-DD CN-APM HH:mm", "YYYY/MM/DD CN-APM HH:mm",
|
|
495
|
+
"YYYY-MM-DD EN-APM", "YYYY/MM/DD EN-APM", "YYYY-MM-DD EN-APM HH:mm", "YYYY/MM/DD EN-APM HH:mm",
|
|
496
|
+
"YYYY年MM月DD日", "YYYY年MM月",
|
|
497
|
+
]);
|
|
498
|
+
if (!KNOWN.has(format))
|
|
499
|
+
return null;
|
|
500
|
+
const pattern = format
|
|
501
|
+
.split("CN-APM").join("\u0001")
|
|
502
|
+
.split("EN-APM").join("\u0002")
|
|
503
|
+
.split("YYYY").join("\\d{4}")
|
|
504
|
+
.split("MM").join("\\d{2}")
|
|
505
|
+
.split("DD").join("\\d{2}")
|
|
506
|
+
.split("HH").join("\\d{2}")
|
|
507
|
+
.split("mm").join("\\d{2}")
|
|
508
|
+
.split("ss").join("\\d{2}")
|
|
509
|
+
.split("\u0001").join("(?:上午|下午)")
|
|
510
|
+
.split("\u0002").join("(?:AM|PM)")
|
|
511
|
+
.replace(/[-/]/g, (ch) => `\\${ch}`);
|
|
512
|
+
return new RegExp(`^${pattern}$`);
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* r17 (2026-10-26) live write-shape evidence on 1.1.25 — every case below was
|
|
516
|
+
* observed on the wire, none is speculative:
|
|
517
|
+
* - DATE(3): a plain string in the field's declared format lands correctly;
|
|
518
|
+
* a millisecond timestamp is ACCEPTED and stored NULL (silent data loss);
|
|
519
|
+
* a date-only or ISO-8601-with-timezone string against a datetime format
|
|
520
|
+
* answers 50000 服务异常.
|
|
521
|
+
* - WORK_SHEET(9): a single relation field takes the bare object id string;
|
|
522
|
+
* an array answers 50000 服务异常.
|
|
523
|
+
* - CHILD_TABLE rows on an UPDATE of an existing record: a row without `id`
|
|
524
|
+
* is INSERTED as a NEW child row (no upsert) — r17: four updates each
|
|
525
|
+
* resending two id-less rows turned 2 child rows into 8, and a roll-up
|
|
526
|
+
* SUM over them silently doubled. The portal always sends the id (→
|
|
527
|
+
* rowStatus Modified) for existing rows; a hand-built payload must too.
|
|
528
|
+
*/
|
|
529
|
+
export function assertRuntimeFieldValueShapes(meta, formData, childCodes, context) {
|
|
530
|
+
for (const [code, value] of Object.entries(formData)) {
|
|
531
|
+
if (value === null || value === undefined)
|
|
532
|
+
continue;
|
|
533
|
+
const field = meta.get(code);
|
|
534
|
+
if (!field)
|
|
535
|
+
continue;
|
|
536
|
+
if (field.type === 3 && typeof value === "number") {
|
|
537
|
+
throw new ToolRefusalError({
|
|
538
|
+
status: "refused",
|
|
539
|
+
blocker: "submit-date-shape",
|
|
540
|
+
schemaCode: context.schemaCode,
|
|
541
|
+
field: code,
|
|
542
|
+
message: `日期字段 ${code} 收到数字 ${value}(毫秒时间戳):平台接受但**静默落库 NULL**(r17 真机)。` +
|
|
543
|
+
`日期必须写成字段 format 对应的字符串(本字段 format=${field.format ?? "未知"}),如 "2026-10-26 11:00:00"。`,
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
if (field.type === 3 && typeof value === "string" && field.format) {
|
|
547
|
+
const re = dateFormatRegex(field.format);
|
|
548
|
+
if (re && !re.test(value.trim())) {
|
|
549
|
+
throw new ToolRefusalError({
|
|
550
|
+
status: "refused",
|
|
551
|
+
blocker: "submit-date-shape",
|
|
552
|
+
schemaCode: context.schemaCode,
|
|
553
|
+
field: code,
|
|
554
|
+
format: field.format,
|
|
555
|
+
message: `日期字段 ${code} 的值 "${value}" 不匹配字段声明的 format "${field.format}"——平台直接回 50000 服务异常(r17 真机:仅日期串/ISO8601 带时区都打回)。` +
|
|
556
|
+
`按 format 逐位对齐;改格式走 field.update。`,
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
if (field.type === 9 && Array.isArray(value)) {
|
|
561
|
+
// Same refusal as the post-hoc diagnoseRelationShapeFailure (which stays
|
|
562
|
+
// as the backstop for nested child-row relations); pre-write is cheaper.
|
|
563
|
+
throw new ToolRefusalError({
|
|
564
|
+
status: "refused",
|
|
565
|
+
blocker: "relation-single-array-shape",
|
|
566
|
+
schemaCode: context.schemaCode,
|
|
567
|
+
fields: [code],
|
|
568
|
+
message: `单选关联字段 ${code}(WORK_SHEET/propertyType 9)传了数组:平台稳定回 50000(r17b 2026-10-26 真机)。` +
|
|
569
|
+
`单值关联写 **id 字符串或 {id}**;多值关联(MULT_WORK_SHEET 11)与人员/组织(50/51)才用数组。`,
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
if (context.isUpdate) {
|
|
574
|
+
const problems = [];
|
|
575
|
+
for (const code of childCodes) {
|
|
576
|
+
const rows = formData[code];
|
|
577
|
+
if (!Array.isArray(rows))
|
|
578
|
+
continue;
|
|
579
|
+
const missing = rows.filter((row) => row !== null &&
|
|
580
|
+
typeof row === "object" &&
|
|
581
|
+
!Array.isArray(row) &&
|
|
582
|
+
!(typeof row.id === "string" && row.id.trim().length > 0) &&
|
|
583
|
+
!(typeof row.rowStatus === "string" && row.rowStatus.trim().length > 0));
|
|
584
|
+
if (missing.length > 0)
|
|
585
|
+
problems.push(`${code}(${missing.length}/${rows.length} 行无 id/rowStatus)`);
|
|
586
|
+
}
|
|
587
|
+
if (problems.length > 0) {
|
|
588
|
+
throw new ToolRefusalError({
|
|
589
|
+
status: "refused",
|
|
590
|
+
blocker: "submit-child-rows-missing-id",
|
|
591
|
+
schemaCode: context.schemaCode,
|
|
592
|
+
childTables: problems,
|
|
593
|
+
message: `更新既有记录时子表行必须带 id:${problems.join("、")}。平台对无 id 的子行按**新增插入**(不判重)——` +
|
|
594
|
+
`r17 真机:同一条记录连发 4 次带 2 行无 id 子表的 update,子行从 2 变 8,聚合 SUM 翻倍。` +
|
|
595
|
+
`改既有行:先 data.load 拿子行 id 原样带回(平台走 rowStatus=Modified);` +
|
|
596
|
+
`有意追加新行:显式给该行 rowStatus:"Added"。`,
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
}
|
|
445
600
|
}
|
|
446
601
|
/** Runtime business-object data keyed by field code. */
|
|
447
602
|
export function registerRuntimeDataLane(tools) {
|
|
@@ -766,7 +921,9 @@ export function registerRuntimeDataLane(tools) {
|
|
|
766
921
|
message: "submit:true 与 workflowCode 互斥——workflowCode 是流程草稿两段式的第一段(返回 workItemId 后用 data.submit 提交);无流程模型的一调完成态用 submit:true 且不带 workflowCode",
|
|
767
922
|
});
|
|
768
923
|
const { sheetCode, resolvedFrom } = await resolveFormSheetCode(session, { schemaCode: args.schemaCode, sheetCode: args.sheetCode });
|
|
769
|
-
const
|
|
924
|
+
const rawSaveData = args.formData;
|
|
925
|
+
const saveIsUpdate = typeof rawSaveData.id === "string" && rawSaveData.id.trim().length > 0;
|
|
926
|
+
const formData = (await prepareRuntimeFormData(session, args.schemaCode, rawSaveData, { isUpdate: saveIsUpdate })).formData;
|
|
770
927
|
if (typeof args.workflowCode === "string" && args.workflowCode.trim()) {
|
|
771
928
|
// Portal draft-save shape. The row id travels in data.id (the portal
|
|
772
929
|
// form SDK receives a pre-generated id from form/load and echoes it);
|
|
@@ -932,7 +1089,7 @@ export function registerRuntimeDataLane(tools) {
|
|
|
932
1089
|
message: "no live business object with this id; refusing to update (the platform would otherwise insert a new row)",
|
|
933
1090
|
});
|
|
934
1091
|
}
|
|
935
|
-
const formData = (await
|
|
1092
|
+
const formData = (await prepareRuntimeFormData(session, args.schemaCode, args.formData, { isUpdate: true })).formData;
|
|
936
1093
|
const performed = await wireCall(session, {
|
|
937
1094
|
path: ENDPOINTS.runtimeFormSave,
|
|
938
1095
|
method: "POST",
|
|
@@ -1066,7 +1223,7 @@ export function registerRuntimeDataLane(tools) {
|
|
|
1066
1223
|
});
|
|
1067
1224
|
}
|
|
1068
1225
|
const { sheetCode, resolvedFrom } = await resolveFormSheetCode(session, { schemaCode: args.schemaCode, sheetCode: args.sheetCode });
|
|
1069
|
-
const stampedSubmit = await
|
|
1226
|
+
const stampedSubmit = await prepareRuntimeFormData(session, args.schemaCode, args.formData, { isUpdate: targetObjectId !== null });
|
|
1070
1227
|
const formData = stampedSubmit.formData;
|
|
1071
1228
|
// r5-asset 2026-10-20: the platform APPENDS child rows carried in an
|
|
1072
1229
|
// approval submit's formData (no upsert by row id, no error). Live: an
|
package/dist/recipes/field.js
CHANGED
|
@@ -260,14 +260,14 @@ const formulaAggregationCrossModel = Object.freeze({
|
|
|
260
260
|
"`propertyType`/`currentPropertyType` 是 BizPropertyType 的**整数码**(0=SHORT_TEXT 2=NUMERICAL 9=WORK_SHEET…),`operatorType` 是 OperatorType 的**整数码**(1=EQ 6=ITEQ 9=IS_NULL 10=IS_NOT_NULL…)。写名字会被 ObjectMapperUtils 抛 json processing exception 并掩码成 305003;工具会自动转码。",
|
|
261
261
|
"`calculateType` 是**字符串**(SUM/COUNT/COUNTIF/SUMIF/AVG/MAX/MIN/LARGE/SMALL/UNION),不是枚举 code。",
|
|
262
262
|
"发布后平台会**自动生成一条 t100 CALCULATE_RULE 镜像行**(h_biz_data_rule,propertyCode=字段 code),它是回算触发的前置条件,别手动删。",
|
|
263
|
-
"
|
|
263
|
+
"**回算时机(r17b 2026-10-26 真机完整行为图谱)**:① 目标行**自己 submit** 时镜像规则一定算(最可靠,不依赖事件);② 来源行 submit/update/delete 触发 `RollUpEventListener`→`FormulaPropertySupport` 事件回算——FIXED+DYNAMIC 混用、多组 OR、COUNTIF/MAX、子表行级条件(propertyCode 带 `.`)都验证过,DELETE 也会回算;③ ⚠️ 事件回算**只在目标模型发布后的几分钟内可靠**,之后静默停止(源端数据变了目标字段不更新、无报错)——重新 `field.publish --force` 目标模型即可恢复。定时巡检/对账场景务必重发或依赖目标行写入路径。",
|
|
264
264
|
],
|
|
265
265
|
priorToolReferences: [
|
|
266
266
|
{ tool: "field.list", args: { environment, schemaCode: "r17_c", published: true }, purpose: "确认来源模型与其列已发布,取 dataItemCode 与 DYNAMIC 条件的 propertyCode" },
|
|
267
267
|
{ tool: "field.list", args: { environment, schemaCode: "r17_d", published: true }, purpose: "确认目标模型已有所承载匹配值的字段(currentPropertyCode)" },
|
|
268
268
|
],
|
|
269
269
|
dryRun: { supported: true, command: `omc call field.create --env ${environment} --dry-run --args '<recipe.args>'`, notes: "dry-run 校验 options 形状与聚合目标;同模型聚合在此拒绝" },
|
|
270
|
-
expectedReadback: { readback: { fieldPresent: true, options: { formulaConfig: "<含 AGGREGATION + DYNAMIC 条件>" } }, next: ["field.publish", "data.submit
|
|
270
|
+
expectedReadback: { readback: { fieldPresent: true, options: { formulaConfig: "<含 AGGREGATION + DYNAMIC 条件>" } }, next: ["field.publish", "data.submit 来源行(紧接发布后几分钟内)→ data.load 目标行读到合计", "form.publish"] },
|
|
271
271
|
refusalRecovery: [
|
|
272
272
|
{ code: "aggregation-self-schema-cycle", action: "把 schemaCode 换成另一个模型;同模型/自己的子表聚合平台不支持" },
|
|
273
273
|
{ code: "duplicate-field", action: "字段已存在改用 field.update 或换 code" },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stalfh233/omc-cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.97",
|
|
4
4
|
"description": "CloudPivot 8.6 configuration capability CLI — governed, fail-closed, coverage-manifest bounded.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -50,9 +50,9 @@
|
|
|
50
50
|
"access": "public"
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@stalfh233/omc-fake-platform": "0.4.
|
|
53
|
+
"@stalfh233/omc-fake-platform": "0.4.97",
|
|
54
54
|
"commander": "14.0.3",
|
|
55
|
-
"omc-core": "0.4.
|
|
55
|
+
"omc-core": "0.4.97",
|
|
56
56
|
"proper-lockfile": "4.1.2",
|
|
57
57
|
"write-file-atomic": "6.0.0",
|
|
58
58
|
"zod": "^3.23.8",
|
|
@@ -156,7 +156,7 @@ rules:
|
|
|
156
156
|
- **`queryConditions` 不能为空**:它是反向过滤器的唯一输入,空数组时回算日志照打但目标字段恒 0(r17 实测)。
|
|
157
157
|
- **DYNAMIC 条件的两个 code 分属两侧,写反了静默 0**:`propertyCode` = 来源模型字段,`currentPropertyCode` = 目标模型字段;语义「来源行的 X 等于本目标行的 Y」。
|
|
158
158
|
- **`propertyType`/`currentPropertyType`(BizPropertyType)与 `operatorType`(OperatorType)必须写整数**——两者都是 `@JsonCreator get(int)`:propertyType 0=SHORT_TEXT 2=NUMERICAL 3=DATE 9=WORK_SHEET…;operatorType 1=EQ 2=NEQ 3=GT 4=IT(小于)5=GTEQ 6=ITEQ(小于等于)9=IS_NULL 10=IS_NOT_NULL…。写名字会被 `ObjectMapperUtils` 抛 json processing exception,再被死循环校验掩码成 305003(工具接受名字并自动转码)。`calculateType` 相反,是**字符串**(SUM/COUNT/COUNTIF/SUMIF/AVG/MAX/MIN/LARGE/SMALL/UNION)。
|
|
159
|
-
-
|
|
159
|
+
- **回算时机(r17b 2026-10-26 完整行为图谱)**:① 目标行**自己 submit** 时镜像规则一定算(最可靠);② 来源行 submit/update/**delete** 触发 `RollUpEventListener`→`FormulaPropertySupport` 事件回算(FIXED+DYNAMIC 混用、多组 OR、COUNTIF/MAX、子表行级条件都验过);③ ⚠️ 事件回算**只在目标模型发布后几分钟内可靠**,之后静默停止(无报错)——`field.publish --force` 目标模型恢复。发布后平台自动生成 t100 `CALCULATE_RULE` 镜像行,别删。r17 真机:目标行 srcNo=C1、来源 C1 行 amt=100 → 读 100;再提交一条 C1/amt=5 → 读 105。
|
|
160
160
|
- **普通数值字段 + 计算规则(type2)不能替代**:REST 路径永不执行(`CalculateHandler.java:50` 的 `forceExecuteCalculateRule` 从不置真),门户里才算。
|
|
161
161
|
- **DYNAMIC 来源不要写点号**:`dataActions` 的 `currentDataItemValue` 写 `items.sealNo` 会被引擎当成**子表列集合**(DatasetImpl),标量目标收到集合 → 运行期 `submit/update` 报 50000(`WorkSheetBizField.fillClientContent: Incorrect value type`)。单跳关联字段直接写字段 code(引擎取 `.id`);子表列只能用于公式/聚合。`rule.save` 已写前拒绝(`rule-graph-create-dynamic-source-is-child-collection`)。
|
|
162
162
|
- **单选字段的 `dataCondition` 在 1.1.25 不生效**:用 FIXED 值比较 RADIO/CHECKBOX/DROPDOWN 时,运行时日志「解析选框类型数据项的比较值失败」,条件被跳过、节点**无条件执行**(r8:异常归还被当成已归还回写)。`rule.save` 会把它放进 `readback.lintHazards`(`rule-graph-radio-condition-unparseable`);改用 BRANCH 判断该字段,或拆两条规则。
|
|
@@ -36,6 +36,7 @@ omc help omc-write-hazards
|
|
|
36
36
|
- **`mappings` 由表单运行时在用户选择时套用**,服务端 `data.save` **不会**自动回填映射目标字段。API 侧要自己写被映射字段,否则读回为空——别把它误判成“映射配置失败”。
|
|
37
37
|
- **关联映射的事实源在字段行 `options.mappings`,不在表单控件属性**(真机 2026-10-14 实证:改表单 attributes 里的映射会被平台按字段重算覆盖、回读不生效)。改映射走:`field.update` 按完整 options 提交(只改 mappings,其余键原值保留)→ `field.publish` → `form.draft`(**rebuild:false**,保留现有布局与在线JS)重存 → `form.publish` → 回读核对。**别用 cascadeRepublish 刷带布局/在线JS 的表单**:rebuild 会清空设计器公式(已做写前拒绝)。
|
|
38
38
|
- **运行时写值的最小形状**(组合出错只会得到一个不透明的 `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>":<值>, ...}]`。先各写一条最小行通过,再组合。
|
|
39
|
+
其中三类已有**写前拒绝**(r17b 2026-10-26 起):日期收到毫秒时间戳(平台接受但落库 NULL)、日期字符串不匹配字段声明 format(50000)、单值关联收到数组(50000)——blocker `submit-date-shape` / `submit-relation-shape`。
|
|
39
40
|
- **DATE(3) 字段的 `options.format` 必须是平台大写枚举**(`YYYY-MM-DD`、`YYYY-MM-DD HH:mm:ss`、`YYYY年MM月DD日`…),**不是 Java 的 `yyyy-MM-dd`**:平台 `DateBizField` 按字面比较,Java 写法能创建字段但**首次运行时 save 稳定 `50000`(`不支持的format`)**(2026-09-30 R5 实测)。`field.create`/`intent` 会就地拒绝非枚举值。
|
|
40
41
|
|
|
41
42
|
- **多选关联(11) 的写入源码依据**(1.1.25 实机 jar):`WorkSheetMultiBizField.contentFromClient` 只接受 `String` 或 `Map`,其它类型直接 `AppAsserts.isTrue(false, "Incorrect value type, should be string or map.")` → 表现为 50000;`contentToDbValue()` 返回 `map.get("id")`,而读回时按 `";"` 切分(`MULTI_WORK_SHEET_SPILT`)并组装 `items` 数组。所以**数组是读写不对称的读侧形状,不能拿来写**;分号串才是写侧形状。实测三种形状:`"<id>"`/`"<id>;<id>"` 成功、`["<id>"]` 50000。
|
|
@@ -89,6 +90,7 @@ omc call field.publish --env local --args '{"schemaCode":"expense"}'
|
|
|
89
90
|
|
|
90
91
|
父 `CHILD_TABLE` 行不接受 `options/choices/subFields` 更新;子列直接走上面的 CRUD 面。
|
|
91
92
|
|
|
93
|
+
- **更新既有记录时,子表行必须带 `id`**(r17b 2026-10-26 真机):`data.update`/`data.submit(bizObjectId)` 里无 `id` 也无显式 `rowStatus` 的子行,平台按**新增插入**(不判重)——同一条记录连发 4 次带 2 行无 id 子表的 update,子行从 2 变 8,聚合 SUM 翻倍。改既有行:先 `data.load` 拿子行 id 原样带回(→ rowStatus=Modified);有意追加新行显式写 `rowStatus:"Added"`。OMC 已写前拒绝 `submit-child-rows-missing-id`。
|
|
92
94
|
- **提交/审批都不要再回传子表数组**(真机 2026-10-19/20):`data.save` 建草稿后 `data.submit` 若带子表行(无 `id`),平台按新增追加(3→9);**审批提交同样**——`data.submit` 带 `workItemId`+`agree` 推进一步时回传子表,每级再 +1 行(1→2→3)。金额若显式传了合计不会翻倍,但行数与 SUM 口径已被污染,且没有报错。审批只回传标量字段(`agree`/意见/被改标量);确需改子行时带 `id`+`rowStatus:"Modified"`。验收加一条"审批后子表行数不变"。
|
|
93
95
|
|
|
94
96
|
## 写调用纪律
|
|
@@ -103,10 +105,11 @@ omc call field.publish --env local --args '{"schemaCode":"expense"}'
|
|
|
103
105
|
(早前「覆盖传入值、API 可判定」的结论来自 r12,已被推翻——不要再用它做验收。)
|
|
104
106
|
- 源码门控:`CalculateHandler.java:50` 的 `forceExecuteCalculateRule` 只在 `BizObjectOptions.executeCalRule()` 为真时求值,而该 builder 全库 0 调用方(REST 路径从不置真)。
|
|
105
107
|
- 所以「计算规则是否生效」**不能用 API 判定**;正确性只能在门户表单里验。API 调用方请自己算好再写。
|
|
106
|
-
- **跨模型汇总可以走 FORMULA(100) + AGGREGATION,而且 REST 能回算**(r17 真机 100→105
|
|
108
|
+
- **跨模型汇总可以走 FORMULA(100) + AGGREGATION,而且 REST 能回算**(r17 真机 100→105;r17b 2026-10-26 补齐行为图谱):
|
|
107
109
|
`aggregation.schemaCode` 指向**另一个模型**,`queryConditions` 必须给 DYNAMIC 条件
|
|
108
|
-
(`propertyCode`=来源模型字段、`currentPropertyCode
|
|
109
|
-
`
|
|
110
|
+
(`propertyCode`=来源模型字段、`currentPropertyCode`=目标模型字段)。配方:`omc describe field.create --example formula-aggregation-cross-model`。
|
|
111
|
+
已验证的形状:FIXED+DYNAMIC 混用、多组 OR、COUNTIF/MAX、子表行级条件(`propertyCode` 带 `.`)、来源行 DELETE 也会回算。
|
|
112
|
+
**回算可靠性(重要)**:① 目标行**自己 submit** 时镜像规则一定算(最可靠);② 来源行 submit/update/delete 的事件回算**只在目标模型发布后几分钟内可靠**,之后静默停止(无报错,源变了目标不更新)——重新 `field.publish --force` 目标模型恢复。验收:发布后立刻改来源行并读目标字段;长期对账别依赖事件回算。
|
|
110
113
|
- **同模型(含自己的子表)聚合不可用**:`field.publish` 必抛 305003 —— `publishFormula` 把相对码改写成子表父模型(= 本模型),
|
|
111
114
|
`checkCycleOfAggregation:1032` 判成环。与 payload 无关,8.6.26 与 1.1.25 代码相同。OMC 已写前拒绝 `aggregation-self-schema-cycle`。
|
|
112
115
|
- **区别:`FORMULA`(计算公式,`propertyType:100`)字段的 EXPRESSION 模式仍只在前端算** —— API 写入不回填。
|