w-web-perm 1.1.13 → 1.1.14

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.
Files changed (107) hide show
  1. package/README.md +70 -6
  2. package/dist/css/{app.f09c7433.css → app.f24c5e50.css} +1 -1
  3. package/dist/index.tmp +1 -1
  4. package/dist/js/app-legacy.033deb22.js +1 -0
  5. package/dist/js/app.033deb22.js +1 -0
  6. package/dist/js/{chunk-vendors.ece5a361.js → chunk-vendors-legacy.b0bbb5ac.js} +4 -4
  7. package/dist/js/{chunk-vendors-legacy.ece5a361.js → chunk-vendors.b0bbb5ac.js} +4 -4
  8. package/dist/w-web-perm.umd.js +2 -2
  9. package/dist/w-web-perm.umd.js.map +1 -1
  10. package/docs/WWebPerm.html +11 -6
  11. package/docs/global.html +11316 -5113
  12. package/docs/index.html +2 -2
  13. package/docs/server_WWebPerm.mjs.html +114 -334
  14. package/docs/server_cascadeWrite.mjs.html +5 -26
  15. package/docs/server_dataChannel.mjs.html +3 -3
  16. package/docs/server_keyCheck.mjs.html +150 -23
  17. package/docs/server_lockSave.mjs.html +2 -2
  18. package/docs/server_maskLog.mjs.html +2 -2
  19. package/docs/server_procCore.mjs.html +246 -62
  20. package/docs/server_staLogs_resolveShellWorker.mjs.html +2 -2
  21. package/docs/server_syncCore.mjs.html +533 -0
  22. package/docs/server_syncKeep.mjs.html +27 -20
  23. package/docs/server_tools_authRefsAudit.mjs.html +2 -2
  24. package/docs/server_tools_authRefsClean.mjs.html +2 -2
  25. package/docs/server_tools_fromTools.mjs.html +2 -2
  26. package/docs/server_tools_isMain.mjs.html +2 -2
  27. package/docs/server_tools_nameDupReport.mjs.html +40 -7
  28. package/docs/server_validateRows.mjs.html +2 -2
  29. package/docs/server_wrapInjected.mjs.html +2 -2
  30. package/docs/src_fetchJson.mjs.html +2 -2
  31. package/docs/src_getPermUserInfor.mjs.html +175 -0
  32. package/docs/src_js_applySaved.mjs.html +128 -0
  33. package/docs/src_js_cellEditing.mjs.html +163 -0
  34. package/docs/src_js_cvKpToArr.mjs.html +2 -2
  35. package/docs/src_js_cvKpToObj.mjs.html +2 -2
  36. package/docs/src_js_draftSync.mjs.html +83 -7
  37. package/docs/src_js_eachOwn.mjs.html +2 -2
  38. package/docs/src_js_editorRefRows.mjs.html +2 -2
  39. package/docs/src_js_externalRows.mjs.html +34 -10
  40. package/docs/src_js_externalUi.mjs.html +4 -8
  41. package/docs/src_js_findRefRows.mjs.html +2 -2
  42. package/docs/src_js_findTouchedDup.mjs.html +2 -2
  43. package/docs/src_js_fmtLossyMsg.mjs.html +2 -2
  44. package/docs/src_js_fromAudit.mjs.html +2 -2
  45. package/docs/src_js_getAddImpact.mjs.html +2 -2
  46. package/docs/src_js_getAuthWarn.mjs.html +2 -2
  47. package/docs/src_js_getCascadePreview.mjs.html +2 -2
  48. package/docs/src_js_getDelRefCount.mjs.html +2 -2
  49. package/docs/src_js_getEffMode.mjs.html +2 -2
  50. package/docs/src_js_getKpLossy.mjs.html +2 -2
  51. package/docs/src_js_getNameErrs.mjs.html +2 -2
  52. package/docs/src_js_getOwn.mjs.html +2 -2
  53. package/docs/src_js_getPropKey.mjs.html +2 -2
  54. package/docs/src_js_getRuleEnable.mjs.html +2 -2
  55. package/docs/src_js_isAuthMode.mjs.html +2 -2
  56. package/docs/src_js_joinNames.mjs.html +2 -2
  57. package/docs/src_js_legendFit.mjs.html +2 -2
  58. package/docs/src_js_pairRowsToSnapshot.mjs.html +2 -2
  59. package/docs/src_js_refAudit.mjs.html +2 -2
  60. package/docs/src_js_refCascade.mjs.html +2 -2
  61. package/docs/src_js_refIdentity.mjs.html +500 -0
  62. package/docs/src_js_relDialogSend.mjs.html +2 -2
  63. package/docs/src_js_rowDigest.mjs.html +75 -145
  64. package/docs/src_plugins_dataReloadGuard.mjs.html +2 -2
  65. package/docs/src_plugins_reuseDeletedIds.mjs.html +2 -2
  66. package/docs/src_plugins_submitGuard.mjs.html +2 -2
  67. package/g_provideGrups.mjs +1 -1
  68. package/g_providePemis.mjs +1 -1
  69. package/g_provideTargets.mjs +1 -1
  70. package/g_provideUsers.mjs +1 -1
  71. package/package.json +2 -2
  72. package/server/WWebPerm.mjs +112 -332
  73. package/server/cascadeWrite.mjs +3 -24
  74. package/server/dataChannel.mjs +1 -1
  75. package/server/keyCheck.mjs +148 -21
  76. package/server/procCore.mjs +244 -60
  77. package/server/procLang.mjs +72 -4
  78. package/server/provideTabs.mjs +6 -4
  79. package/server/syncCore.mjs +461 -0
  80. package/server/syncKeep.mjs +25 -18
  81. package/server/tools/nameDupReport.mjs +38 -5
  82. package/src/components/LayoutContentGrups.vue +57 -8
  83. package/src/components/LayoutContentPemis.vue +57 -8
  84. package/src/components/LayoutContentTargets.vue +38 -7
  85. package/src/components/LayoutContentUsers.vue +57 -8
  86. package/src/components/VeGrupBlngUsers.vue +128 -20
  87. package/src/components/VePemiBlngGrups.vue +128 -20
  88. package/src/getPermUserInfor.mjs +10 -0
  89. package/src/js/applySaved.mjs +56 -0
  90. package/src/js/cellEditing.mjs +91 -0
  91. package/src/js/draftSync.mjs +81 -5
  92. package/src/js/externalRows.mjs +32 -8
  93. package/src/js/externalUi.mjs +2 -6
  94. package/src/js/refIdentity.mjs +428 -0
  95. package/src/js/rowDigest.mjs +73 -143
  96. package/src/schema/contracts/grups.mjs +8 -0
  97. package/src/schema/contracts/index.mjs +17 -0
  98. package/src/schema/contracts/pemis.mjs +8 -0
  99. package/src/schema/contracts/targets.mjs +8 -0
  100. package/src/schema/contracts/users.mjs +13 -0
  101. package/src/schema/tables/grups.mjs +4 -0
  102. package/src/schema/tables/pemis.mjs +4 -0
  103. package/src/schema/tables/targets.mjs +4 -0
  104. package/src/schema/tables/users.mjs +4 -0
  105. package/srv.mjs +19 -0
  106. package/dist/js/app-legacy.8af87052.js +0 -1
  107. package/dist/js/app.8af87052.js +0 -1
@@ -0,0 +1,91 @@
1
+ //儲存格編輯器開啟中視為有草稿(第 115 項; R-PRINCIPLE-00 原則 10「有未存修改時收到推播不重建」; 規則帳本 R-DRAFT-KEEP-01 規則 10): 以 ag-grid 的 API 判定, 四清單頁與兩對話框共用.
2
+ //
3
+ //背景: 使用者在儲存格輸入了文字、尚未按 Enter(或尚未失焦)時, 輸入只在編輯器內, 還沒進 rows(rowsChange 尚未觸發, isModified 仍為假); 此時推播使清單重建(setRowData)會直接丟掉編輯器與輸入, 且不會有任何提示.
4
+ //故編輯器開啟中視同有草稿: 不重建、凍結基準、提示「資料已被更新」; 編輯器關閉後(Enter 提交→isModified 為真續保留; Esc 或無變更→無草稿)若 store 於期間已變新, 補上一次重建.
5
+ //
6
+ //cmp 為 WAggridVue 元件實例(含 getApi(): ag-grid GridApi; w-aggrid-vue 2.1.9). 純函數: 不依賴 Vue; 取不到 api(尚未掛載、已銷毀)一律視為「未在編輯」.
7
+
8
+
9
+ let apiOf = (cmp) => {
10
+ try {
11
+ let api = (cmp !== null && cmp !== undefined && typeof cmp.getApi === 'function') ? cmp.getApi() : null
12
+ return (api !== null && api !== undefined && typeof api.getEditingCells === 'function') ? api : null
13
+ }
14
+ catch (err) {
15
+ return null
16
+ }
17
+ }
18
+
19
+
20
+ /**
21
+ * 儲存格編輯器是否開啟中(ag-grid getEditingCells 非空)
22
+ *
23
+ * @param {Object} cmp 輸入 WAggridVue 元件實例(可為 null/undefined)
24
+ * @returns {Boolean} 回傳是否有儲存格正在編輯
25
+ */
26
+ function isCellEditing(cmp) {
27
+ let api = apiOf(cmp)
28
+ if (api === null) {
29
+ return false
30
+ }
31
+ try {
32
+ let cs = api.getEditingCells()
33
+ return Array.isArray(cs) && cs.length > 0
34
+ }
35
+ catch (err) {
36
+ return false
37
+ }
38
+ }
39
+
40
+
41
+ let bound = new WeakSet()
42
+
43
+
44
+ /**
45
+ * 對 ag-grid api 註冊一次「儲存格編輯結束」監聽(同一個 api 只註冊一次; 元件重建即換新 api, 需再呼叫)
46
+ *
47
+ * @param {Object} cmp 輸入 WAggridVue 元件實例
48
+ * @param {Function} onStop 輸入編輯結束(提交、Esc、失焦)後之回呼
49
+ * @returns {Boolean} 回傳是否已綁定(false 為 api 尚未可用, 呼叫端稍後重試)
50
+ */
51
+ function bindCellEditingStop(cmp, onStop) {
52
+ let api = apiOf(cmp)
53
+ if (api === null || typeof api.addEventListener !== 'function') {
54
+ return false
55
+ }
56
+ if (bound.has(api)) {
57
+ return true
58
+ }
59
+ bound.add(api)
60
+ api.addEventListener('cellEditingStopped', () => {
61
+ try {
62
+ onStop()
63
+ }
64
+ catch (err) {
65
+ console.log('cellEditingStopped', err)
66
+ }
67
+ })
68
+ return true
69
+ }
70
+
71
+
72
+ /**
73
+ * 綁定編輯結束監聽, api 尚未可用時以短輪詢重試(元件於 v-if 內, 重建草稿後下一輪渲染才掛載)
74
+ *
75
+ * @param {Function} getCmp 輸入回傳 WAggridVue 元件實例之函數(每次重試重新取, 元件可能被重建)
76
+ * @param {Function} onStop 輸入編輯結束後之回呼
77
+ * @param {Number} [tries=30] 輸入最多重試次數(每次相隔 100 ms)
78
+ * @returns {void} 無回傳
79
+ */
80
+ function bindCellEditingStopRetry(getCmp, onStop, tries = 30) {
81
+ if (bindCellEditingStop(getCmp(), onStop) || tries <= 0) {
82
+ return
83
+ }
84
+ setTimeout(() => {
85
+ bindCellEditingStopRetry(getCmp, onStop, tries - 1)
86
+ }, 100)
87
+ }
88
+
89
+
90
+ export { isCellEditing, bindCellEditingStop, bindCellEditingStopRetry }
91
+ export default isCellEditing
@@ -56,15 +56,91 @@ function planDraftSync({ first, editChanged, storeChanged, hasDraft, saving }) {
56
56
  * 草稿所依據之基準相對於目前 store 整表是否已過期(供顯示「資料已被更新」提示; 判準同伺服端 checkBaseline, 單一擁有者 rowDigest.compareBaseline)
57
57
  *
58
58
  * @param {Object} p 輸入參數物件
59
- * @param {Array} p.keys 輸入該表 schema 欄位名陣列
59
+ * @param {String} p.keyTable 輸入表名(targets/pemis/grups/users; 依其寫入契約計算)
60
60
  * @param {Array} p.rows 輸入目前 store 之該表列
61
61
  * @param {Array} p.baseline 輸入草稿載入時之基準
62
- * @returns {Boolean} 回傳是否過期(有任一列內容不同或列被增刪; 僅 order/伺服器管理欄不同不算)
62
+ * @returns {Boolean} 回傳是否過期(有任一列內容摘要不同、列被增刪或名次不同; 僅 order 數值改變而名次不變、或僅伺服器專有欄不同不算)
63
63
  */
64
- function isDraftStale({ keys, rows, baseline }) {
65
- return isBaselineStale({ keys, rows, baseline })
64
+ function isDraftStale({ keyTable, rows, baseline }) {
65
+ return isBaselineStale({ keyTable, rows, baseline })
66
66
  }
67
67
 
68
68
 
69
- export { refsChanged, planDraftSync, isDraftStale }
69
+ /**
70
+ * 是否有未存之草稿: 已提交之未存修改(isModified), 或**儲存格編輯器開啟中**(已輸入、未按 Enter 或未失焦——編輯器內的輸入尚未進 rows, 但重建清單會直接丟棄它; 第 115 項, R-DRAFT-KEEP-01 規則 10)
71
+ *
72
+ * 四清單頁(changeParams 的 hasDraft)與兩對話框(planDialogSync)共用, 口徑單一.
73
+ *
74
+ * @param {Object} p 輸入參數物件
75
+ * @param {Boolean} p.dirty 輸入是否有已提交之未存修改(isModified 鏡像)
76
+ * @param {Boolean} p.editing 輸入儲存格編輯器是否開啟中(ag-grid getEditingCells 非空)
77
+ * @returns {Boolean} 回傳是否視為有草稿
78
+ */
79
+ function hasDraftNow({ dirty, editing }) {
80
+ return dirty === true || editing === true
81
+ }
82
+
83
+
84
+ /**
85
+ * 反向關聯對話框(開著時)收到 store 推播之處置(第 114/115 項、修 (b); R-PRINCIPLE-00 原則 10「無未存修改時照舊重建」; 規則帳本 R-DRAFT-KEEP-01 規則 10~12)
86
+ *
87
+ * - 'none': 不須處置——未顯示、store 未變、表內容未異於載入基準(推播內容與載入時相同)、或此推播是自己存檔所致(saving)而無草稿
88
+ * - 'rebuild': 無未存修改(含無開啟中之儲存格編輯器)且表已異於載入基準 → 以新資料重建對話框內容並更新基準(對話框開而未編輯時不應停在過期的資料上)
89
+ * - 'keep': 有未存修改或開啟中之編輯器 → 保留草稿、凍結基準; 呼叫端依 stale 顯示「資料已被更新」提示
90
+ * - 'keepQuiet': 自己之存檔進行中(伺服端已寫入、回應未到)所致之推播, 不是他人更新 → 保留、不提示
91
+ *
92
+ * @param {Object} p 輸入參數物件
93
+ * @param {Boolean} p.show 輸入對話框是否顯示中
94
+ * @param {Boolean} p.storeChanged 輸入來源 store 是否變新(參照改變)
95
+ * @param {Boolean} p.stale 輸入表是否已異於對話框載入時之基準(isDraftStale)
96
+ * @param {Boolean} p.dirty 輸入是否有已提交之未存修改
97
+ * @param {Boolean} p.editing 輸入儲存格編輯器是否開啟中
98
+ * @param {Boolean} p.saving 輸入自己之存檔是否進行中
99
+ * @returns {String} 回傳 'none'|'rebuild'|'keep'|'keepQuiet'
100
+ */
101
+ function planDialogSync({ show, storeChanged, stale, dirty, editing, saving }) {
102
+ if (show !== true || storeChanged !== true) {
103
+ return 'none'
104
+ }
105
+ if (saving === true) {
106
+ return 'keepQuiet'
107
+ }
108
+ if (stale !== true) {
109
+ return 'none'
110
+ }
111
+ return hasDraftNow({ dirty, editing }) ? 'keep' : 'rebuild'
112
+ }
113
+
114
+
115
+ /**
116
+ * 驗證關聯對話框之主體(被編輯其歸屬之群組/權限)於存檔當下仍是開框時那一個實體(第 114 項; R-PRINCIPLE-00 原則 3、6; 規則帳本 R-SUBJECT-ID-01)
117
+ *
118
+ * 反向對話框以**名稱**引用主體(使用者 cgrups/群組 cpemis 的鍵是名稱), 不驗主體身分: 開框後主體被刪除並以同名重建(id 不同), 引用會靜默指向新實體(原則 6: 不得讓引用被同名新資料接管);
119
+ * 故以主體 id 與開框時之摘要(id、名稱、開框時是否已儲存)比對存檔當下的主體表: id 仍在、名稱未變才算同一實體. 不符一律拒收(收嚴, 原則 3), 提示重新載入.
120
+ * 開框時主體尚未儲存(store 查無該 id; 清單頁新增列未存即開對話框)者沿用既有行為(引用先指向尚不存在之名稱), 但其後 store 出現「同名而 id 不同」之另一實體時亦拒收(引用會被該實體接管).
121
+ *
122
+ * @param {Object} p 輸入參數物件
123
+ * @param {Object} p.subject 輸入開框時之主體摘要 { id, name, saved }(saved: 開框時 store 已有該 id)
124
+ * @param {Array} p.rows 輸入存檔當下之主體表列(store 的 grups 或 pemis)
125
+ * @returns {String} 回傳 'ok'|'gone'(開框時已儲存之 id 現已不在: 已刪除或以同名重建)|'renamed'(id 仍在但名稱不同)|'taken'(開框時未儲存之主體, 現有同名而 id 不同之另一實體)|'noSubject'(未提供有效之開框摘要, 收嚴拒收)
126
+ */
127
+ function checkSubject({ subject, rows }) {
128
+ let id = (subject !== null && typeof subject === 'object') ? subject.id : undefined
129
+ let name = (subject !== null && typeof subject === 'object') ? subject.name : undefined
130
+ if (typeof id !== 'string' || id === '' || typeof name !== 'string' || name === '') {
131
+ return 'noSubject'
132
+ }
133
+ let list = (Array.isArray(rows) ? rows : []).filter((r) => r !== null && typeof r === 'object')
134
+ let hit = list.find((r) => r.id === id)
135
+ if (hit !== undefined) {
136
+ return hit.name === name ? 'ok' : 'renamed'
137
+ }
138
+ if (subject.saved === true) {
139
+ return 'gone'
140
+ }
141
+ return list.some((r) => r.name === name) ? 'taken' : 'ok'
142
+ }
143
+
144
+
145
+ export { refsChanged, planDraftSync, isDraftStale, hasDraftNow, planDialogSync, checkSubject }
70
146
  export default planDraftSync
@@ -24,6 +24,12 @@ let isObj = (v) => v !== null && typeof v === 'object'
24
24
  let normV = (v) => JSON.stringify(v === undefined ? '' : v)
25
25
 
26
26
 
27
+ //normC: 內容欄比對用——另視 null 與 ''/缺欄為同一空值(寫入契約之空值等價, 與基準 digestRow 同口徑; 0/false 仍異於空值).
28
+ // 基準已視 null≡'' 為相同而通過, 此處若仍視為不同, 會對合法存檔回 saveExternalRowChanged(外審 sol#2, 2026-10-23); 相同者亦不重寫, 庫內原表示(null)不被改成 ''.
29
+ // from 欄不適用: null 與 '' 之 from 有別(歷史後台列, 見 checkExternalWrite), 一律用 normV.
30
+ let normC = (v, k) => (k === 'from' ? normV(v) : JSON.stringify(v === undefined || v === null ? '' : v))
31
+
32
+
27
33
  /**
28
34
  * 判斷是否為外部列(from 為非空字串)
29
35
  *
@@ -85,15 +91,17 @@ function restoreExternalOrder(rows, ltdtOld) {
85
91
  * 將「外部列且僅伺服器管理欄(建立者/時間、更新者/時間)不同」之 diff 列移入 same(視為無變更, 不寫入)
86
92
  *
87
93
  * 外部同步每次整來源重寫, 使外部列之 timeUpdate/timeCreate/userId 隨之更新; 持舊快照之後台存檔若原樣送回該列, 其內容與庫內相同而僅伺服器管理欄較舊,
88
- * 寫入會把建立者與建立時間回退為舊值(D2 設計稿 S2). 內容相同即無須寫入. 只處理外部列, 後台列之 diff 判定不變.
94
+ * 寫入會把建立者與建立時間回退為舊值(D2 設計稿 S2). 內容相同即無須寫入. 預設只處理外部列, 後台列之 diff 判定不變.
95
+ * all 為真時不分外部列或後台列一律處理(perm-run01: 寫入契約 serverOnly——伺服器專有欄位存檔不採用客戶端值, 規格第 4、6 節; R-CONTRACT-01).
89
96
  *
90
97
  * @param {Object} p 輸入參數物件
91
98
  * @param {Array} p.keys 輸入該表 schema 欄位名陣列
92
99
  * @param {Object} p.r 輸入 ltdtDiffByKey 結果 { add, del, diff, same }
93
100
  * @param {Array} p.ltdtOld 輸入該表庫內全部列
101
+ * @param {Boolean} [p.all=false] 輸入是否不分外部列或後台列(預設只外部列)
94
102
  * @returns {Object} 回傳新之 { add, del, diff, same }
95
103
  */
96
- function dropServerOnlyDiff({ keys, r, ltdtOld }) {
104
+ function dropServerOnlyDiff({ keys, r, ltdtOld, all = false }) {
97
105
  let oldById = new Map()
98
106
  ltdtOld.forEach((o) => {
99
107
  if (isObj(o) && isStr(o.id)) {
@@ -105,7 +113,7 @@ function dropServerOnlyDiff({ keys, r, ltdtOld }) {
105
113
  let same = [...r.same]
106
114
  r.diff.forEach((row) => {
107
115
  let o = isObj(row) && isStr(row.id) ? oldById.get(row.id) : undefined
108
- if (o !== undefined && isExternalRow(o) && fields.every((k) => normV(row[k]) === normV(o[k]))) {
116
+ if (o !== undefined && (all === true || isExternalRow(o)) && fields.every((k) => normC(row[k], k) === normC(o[k], k))) {
109
117
  same.push(row)
110
118
  }
111
119
  else {
@@ -121,14 +129,16 @@ function dropServerOnlyDiff({ keys, r, ltdtOld }) {
121
129
  *
122
130
  * 伺服器管理欄由伺服器擁有(procOrm save 只覆寫 userIdUpdate/timeUpdate, 建立者與建立時間取自送入之列); 外部同步每次整來源重寫使外部列之建立者/建立時間更新,
123
131
  * 持舊草稿(前端保留未存編輯, 見 draftSync)送回該列時, 基準因排除伺服器管理欄而通過(內容未變), 若原樣寫入就把較新之建立者與建立時間回退為舊值(R-EXT-OWN-01 S2 之同一缺陷, 外審 sol 第 3 點).
124
- * 故改動之外部列一律以庫內之伺服器管理欄寫入. 只處理庫內為外部列者(後台列之寫入不變); 庫內無該欄者不補.
132
+ * 故改動之外部列一律以庫內之伺服器管理欄寫入. 預設只處理庫內為外部列者(後台列之寫入不變); 庫內無該欄者(歷史列)不補、亦不採用客戶端送入之值(刪除該欄, 保留庫內值=仍無該欄; perm-run01 外審 sol#3).
133
+ * all 為真時不分外部列或後台列一律處理(perm-run01: 寫入契約 serverOnly, 見 dropServerOnlyDiff).
125
134
  *
126
135
  * @param {Object} p 輸入參數物件
127
136
  * @param {Object} p.r 輸入 ltdtDiffByKey(及 dropServerOnlyDiff)結果 { add, del, diff, same }
128
137
  * @param {Array} p.ltdtOld 輸入該表庫內全部列
138
+ * @param {Boolean} [p.all=false] 輸入是否不分外部列或後台列(預設只外部列)
129
139
  * @returns {Object} 回傳新之 { add, del, diff, same }(不改輸入; 未改動之列為同一物件)
130
140
  */
131
- function restoreServerFields({ r, ltdtOld }) {
141
+ function restoreServerFields({ r, ltdtOld, all = false }) {
132
142
  let oldById = new Map()
133
143
  ltdtOld.forEach((o) => {
134
144
  if (isObj(o) && isStr(o.id)) {
@@ -137,7 +147,7 @@ function restoreServerFields({ r, ltdtOld }) {
137
147
  })
138
148
  let diff = r.diff.map((row) => {
139
149
  let o = isObj(row) && isStr(row.id) ? oldById.get(row.id) : undefined
140
- if (o === undefined || !isExternalRow(o)) {
150
+ if (o === undefined || (all !== true && !isExternalRow(o))) {
141
151
  return row
142
152
  }
143
153
  let out = { ...row }
@@ -145,6 +155,10 @@ function restoreServerFields({ r, ltdtOld }) {
145
155
  if (hasOwn(o, k)) {
146
156
  out[k] = o[k]
147
157
  }
158
+ else {
159
+ //庫內無該欄(歷史列): 不採用客戶端送入之值(寫入契約 serverOnly: 保留庫內值=仍無該欄; 外審 sol#3, 2026-10-23)
160
+ delete out[k]
161
+ }
148
162
  })
149
163
  return out
150
164
  })
@@ -182,6 +196,9 @@ function checkExternalWrite({ keyTable, keys, r, ltdtOld, lockExternalRows = tru
182
196
  bad.push({ key, reason, k, from: isStr(from) ? from : '' })
183
197
  }
184
198
 
199
+ //srcKey: 外部匯入之來源鍵為身分欄(與 from 同; perm-run03, R-IMPORT-01), 只由匯入端點寫入, 後台不可新增帶值之列、不可改既有列之值(不論 lockExternalRows; 缺欄/null/'' 同一空值)
200
+ let normSrc = (v) => (v === undefined || v === null ? '' : v)
201
+
185
202
  //add
186
203
  r.add.forEach((row, k) => {
187
204
  let f = isObj(row) ? normFrom(row.from) : ''
@@ -191,6 +208,9 @@ function checkExternalWrite({ keyTable, keys, r, ltdtOld, lockExternalRows = tru
191
208
  else if (lockExternalRows && f !== '') {
192
209
  push('saveFromNotAllowed', 'addFrom', k, f)
193
210
  }
211
+ else if (isObj(row) && normSrc(row.srcKey) !== '') {
212
+ push('saveFromNotAllowed', 'addSrcKey', k, '')
213
+ }
194
214
  })
195
215
 
196
216
  //del
@@ -211,6 +231,10 @@ function checkExternalWrite({ keyTable, keys, r, ltdtOld, lockExternalRows = tru
211
231
  push('saveFromNotAllowed', 'fromType', k, '')
212
232
  return
213
233
  }
234
+ if (o !== undefined && isObj(row) && normSrc(row.srcKey) !== normSrc(o.srcKey)) {
235
+ push(isExternalRow(o) ? 'saveExternalRowChanged' : 'saveFromNotAllowed', 'srcKeyChanged', k, isExternalRow(o) ? o.from : '')
236
+ return
237
+ }
214
238
  if (!lockExternalRows || o === undefined) {
215
239
  return
216
240
  }
@@ -218,7 +242,7 @@ function checkExternalWrite({ keyTable, keys, r, ltdtOld, lockExternalRows = tru
218
242
  if (nf !== o.from) {
219
243
  push('saveExternalRowChanged', 'fromChanged', k, o.from)
220
244
  }
221
- else if (lockFields.some((f) => normV(row[f]) !== normV(o[f]))) {
245
+ else if (lockFields.some((f) => normC(row[f], f) !== normC(o[f], f))) {
222
246
  push('saveExternalRowChanged', 'fieldChanged', k, o.from)
223
247
  }
224
248
  }
@@ -262,5 +286,5 @@ function hasExternalDiff({ r, ltdtOld }) {
262
286
  }
263
287
 
264
288
 
265
- export { SERVER_FIELDS, ASSIGN_FIELD, isExternalRow, getLockFields, restoreExternalOrder, dropServerOnlyDiff, restoreServerFields, checkExternalWrite, hasExternalDiff }
289
+ export { SERVER_FIELDS, ASSIGN_FIELD, isExternalRow, getLockFields, restoreExternalOrder, dropServerOnlyDiff, restoreServerFields, checkExternalWrite, hasExternalDiff, normC }
266
290
  export default isExternalRow
@@ -1,4 +1,4 @@
1
- import { isExternalRow, getLockFields } from './externalRows.mjs'
1
+ import { isExternalRow, getLockFields, normC } from './externalRows.mjs'
2
2
 
3
3
 
4
4
  //清單頁(標的/權限/群組/使用者)之外部列(from 為非空字串者)前端輔助純函數(F-23; R-PRINCIPLE-00 原則 3、5; 規則帳本 R-EXT-OWN-01; ADR-037): 不依賴 Vue/DOM/語系檔,
@@ -9,10 +9,6 @@ let isStr = (v) => typeof v === 'string'
9
9
  let isObj = (v) => v !== null && typeof v === 'object'
10
10
 
11
11
 
12
- //normV: 比對用正規化, 同 externalRows 之 checkExternalWrite(缺欄與 '' 視為同一; 其餘以 JSON 字串比對)
13
- let normV = (v) => JSON.stringify(v === undefined ? '' : v)
14
-
15
-
16
12
  /**
17
13
  * 把範本之 {key} 代入(單趟掃描: 代入之值不再解析, 名稱可含 {n}、{from} 等字樣原樣顯示; 未給之佔位符原樣保留)
18
14
  *
@@ -143,7 +139,7 @@ function getExternalEdits({ keyTable, keys, rowsNew, rowsOld }) {
143
139
  return
144
140
  }
145
141
  let r = newById.get(o.id)
146
- if (r === undefined || fields.some((f) => normV(r[f]) !== normV(o[f]))) {
142
+ if (r === undefined || fields.some((f) => normC(r[f], f) !== normC(o[f], f))) {
147
143
  n++
148
144
  if (froms.indexOf(o.from) < 0 && froms.length < 5) {
149
145
  froms.push(o.from)