odant-work 1.0.12 → 1.0.13

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.
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Видимость данных по `scope` ссылок рабочих мест (docs/architecture-v2.md, Р8).
3
+ *
4
+ * Ссылка подразделения на класс (`#security.LINKS` места или общая) несёт `scope`:
5
+ * `own` — свои объекты (автор), `place` — объекты своего места, `unit` — подразделения
6
+ * с вложенными, `all` — все. Без `scope`: справочники — `all`, операции и счета — `unit`.
7
+ * Отношение пользователя к ссылке (`scopeLinks` в sources/server/access/links.js):
8
+ * `member` — scope ссылки; `ancestor` (вышестоящее место видит ссылки вложенных) — то же,
9
+ * но «свои» и «место» — объекты вложенного места; `boss` — подразделение с вложенными
10
+ * (`all` остаётся `all`); `admin` — всё.
11
+ *
12
+ * Ссылка на класс вызова или его предка открывает всё поддерево класса, ссылка ниже —
13
+ * только свою ветку: сводный отчёт предка строится по видимым веткам.
14
+ *
15
+ * Право (grant) — `{ cids?, units?, places?, author? }`: ветка классов, подразделения,
16
+ * пары [подразделение, место], автор. Условия права — И, права между собой — ИЛИ.
17
+ * Список прав `null` — видно всё, `[]` — ничего. Для проводок ветка становится
18
+ * счетами (`accounts`) или классами операций (`ops`) — `entryGrants`.
19
+ *
20
+ * Место хранится путём в дереве ROLES подразделения ('heads/ceo') и имеет смысл только
21
+ * вместе с подразделением (`unit` — cid структуры).
22
+ */
23
+ import { getClass, descendants } from './classes.js';
24
+ import { FAMILY } from './schema.js';
25
+ import { DataError } from './errors.js';
26
+
27
+ export const SCOPES = Object.freeze(['own', 'place', 'unit', 'all']);
28
+
29
+ const isEmpty = v => v === undefined || v === null || v === '';
30
+ const under = (path, id) => path === id || path.startsWith(id + '/');
31
+
32
+ /** Видимость по ссылке без `scope`: справочники — все объекты, операции и счета — подразделения. */
33
+ export function defaultScope(type) {
34
+ return FAMILY[type] === 'catalog' ? 'all' : 'unit';
35
+ }
36
+
37
+ /** Подразделение ссылки: cid структуры в кэше классов, иначе путь. */
38
+ function unitOf(db, structure) {
39
+ return getClass(db, structure)?.cid ?? structure;
40
+ }
41
+
42
+ function unitTree(db, unit) {
43
+ const list = descendants(db, unit);
44
+ return list.length ? list : [unit];
45
+ }
46
+
47
+ /** Условия строк права по ссылке и её отношению к пользователю. */
48
+ function rowsOf(db, link, scope, uid) {
49
+ const unit = unitOf(db, link.structure);
50
+ if (link.relation === 'admin' || scope === 'all')
51
+ return {};
52
+ if (link.relation === 'boss' || scope === 'unit')
53
+ return { units: unitTree(db, unit) };
54
+ if (link.relation === 'ancestor')
55
+ return { places: [[unit, link.place]] };
56
+ if (scope === 'place') {
57
+ const places = link.place ? [link.place] : link.places ?? [];
58
+ if (places.length)
59
+ return { places: places.map(p => [unit, p]) };
60
+ }
61
+ return { author: uid ?? '' };
62
+ }
63
+
64
+ /**
65
+ * Права видимости данных через класс вызова.
66
+ * @param {object} cls Класс вызова из кэша (cid, path, type)
67
+ * @param {Array<{ structure, place, id, access, scope, relation, places }>} links Ссылки пользователя (scopeLinks)
68
+ * @param {object} [opts] { uid, write — только ссылки с правом записи }
69
+ * @returns {Array<object>|null} null — видно всё
70
+ */
71
+ export function scopeGrants(db, cls, links, { uid, write = false } = {}) {
72
+ const grants = new Map();
73
+ for (const l of links || []) {
74
+ if (write && l.access !== 'write' && l.access !== 'admin')
75
+ continue;
76
+ let branch = null, type = cls.type;
77
+ if (!under(cls.path, l.id)) {
78
+ if (!l.id.startsWith(cls.path + '/'))
79
+ continue;
80
+ const c = getClass(db, l.id);
81
+ if (!c)
82
+ continue;
83
+ branch = descendants(db, c.cid);
84
+ if (FAMILY[c.type])
85
+ type = c.type;
86
+ }
87
+ const rows = rowsOf(db, l, SCOPES.includes(l.scope) ? l.scope : defaultScope(type), uid);
88
+ if (!branch && !Object.keys(rows).length)
89
+ return null;
90
+ const g = branch ? { cids: branch, ...rows } : rows;
91
+ grants.set(JSON.stringify(g), g);
92
+ }
93
+ return [...grants.values()];
94
+ }
95
+
96
+ /** Права без веток (чтение реестра через виртуальный справочник, проводки объекта). */
97
+ export function withoutBranches(grants) {
98
+ return grants && grants.map(({ cids, ...rows }) => rows);
99
+ }
100
+
101
+ /**
102
+ * Права на проводки отчёта: ветка класса-счёта — счета стороны, класса операций — класс
103
+ * операции; у справочника ветка лишь выбирает права объекта `objectCid` (без него — все).
104
+ */
105
+ export function entryGrants(grants, family, objectCid) {
106
+ if (grants == null)
107
+ return null;
108
+ const out = [];
109
+ for (const { cids, ...rows } of grants) {
110
+ if (!cids)
111
+ out.push(rows);
112
+ else if (family === 'account')
113
+ out.push({ accounts: cids, ...rows });
114
+ else if (family === 'operation')
115
+ out.push({ ops: cids, ...rows });
116
+ else if (objectCid === undefined || cids.includes(objectCid))
117
+ out.push(rows);
118
+ }
119
+ return out;
120
+ }
121
+
122
+ function inList(col, list) {
123
+ const values = [...new Set(list.map(String))];
124
+ return values.length ? [`${col} IN (${values.map(() => '?').join(',')})`, values] : ['0', []];
125
+ }
126
+
127
+ /** Условия строк (подразделения, места, автор) на таблицу с колонками unit/place/author. */
128
+ function rowParts(g, alias, entries) {
129
+ const parts = [];
130
+ if (g.units)
131
+ parts.push(inList(`${alias}.unit`, g.units));
132
+ if (g.places)
133
+ parts.push(g.places.length
134
+ ? [`(${g.places.map(() => `(${alias}.unit = ? AND ${alias}.place = ?)`).join(' OR ')})`, g.places.flat().map(String)]
135
+ : ['0', []]);
136
+ if (g.author !== undefined) {
137
+ // проводку «свою» делает и своя операция, и своё действие (регистрация, исправление)
138
+ parts.push(entries
139
+ ? [`(${alias}.author = ? OR EXISTS (SELECT 1 FROM objects x WHERE x.id = ${alias}.op_id AND x.author = ?))`, [g.author, g.author]]
140
+ : [`${alias}.author = ?`, [g.author]]);
141
+ }
142
+ return parts;
143
+ }
144
+
145
+ function anyOf(grants, partsOf) {
146
+ if (grants == null)
147
+ return null;
148
+ const ors = [], args = [];
149
+ for (const g of grants) {
150
+ const parts = partsOf(g);
151
+ if (!parts.length)
152
+ return null;
153
+ ors.push(parts.map(p => p[0]).join(' AND '));
154
+ args.push(...parts.flatMap(p => p[1]));
155
+ }
156
+ return ors.length ? [`(${ors.map(s => `(${s})`).join(' OR ')})`, args] : ['0', []];
157
+ }
158
+
159
+ /** Условие видимости объектов (алиас таблицы `o`): [sql, args] или null — без ограничений. */
160
+ export function objectCond(grants, alias = 'o') {
161
+ return anyOf(grants, g => [...(g.cids ? [inList(`${alias}.cid`, g.cids)] : []), ...rowParts(g, alias, false)]);
162
+ }
163
+
164
+ /**
165
+ * Условие видимости проводок (алиас `e`, права из `entryGrants`): счета ветки проверяются
166
+ * по колонкам `accCols` — стороне отчёта или обеим сторонам в журнале.
167
+ */
168
+ export function entryCond(grants, accCols = ['e.debit_cid', 'e.credit_cid']) {
169
+ return anyOf(grants, g => {
170
+ const parts = [];
171
+ if (g.accounts) {
172
+ const c = accCols.map(col => inList(col, g.accounts));
173
+ parts.push([`(${c.map(x => x[0]).join(' OR ')})`, c.flatMap(x => x[1])]);
174
+ }
175
+ if (g.ops)
176
+ parts.push(inList('e.op_cid', g.ops));
177
+ return [...parts, ...rowParts(g, 'e', true)];
178
+ });
179
+ }
180
+
181
+ /** Подразделения, данные которых видны по правам (фильтр `units` формы): null — все. */
182
+ export function visibleUnits(grants) {
183
+ if (grants == null)
184
+ return null;
185
+ const set = new Set();
186
+ for (const g of grants) {
187
+ for (const u of g.units ?? [])
188
+ set.add(u);
189
+ for (const [u] of g.places ?? [])
190
+ set.add(u);
191
+ }
192
+ return [...set];
193
+ }
194
+
195
+ /**
196
+ * Подразделения и места, от имени которых пользователь пишет в класс: ссылки на запись
197
+ * на класс или его предка. ADMIN подразделения — любое его место (`any`).
198
+ * @returns {Array<{ unit, unit_label, place, place_label, any? }>}
199
+ */
200
+ export function writeContexts(db, cls, links) {
201
+ const out = new Map();
202
+ const add = (l, place, any = false) => {
203
+ const unit = unitOf(db, l.structure);
204
+ const key = `${unit}\n${place ?? ''}`;
205
+ const cur = out.get(key);
206
+ if (cur) {
207
+ cur.any ||= any;
208
+ return;
209
+ }
210
+ out.set(key, {
211
+ unit,
212
+ unit_label: getClass(db, unit)?.label ?? unit,
213
+ place,
214
+ place_label: place ? l.labels?.[place] ?? place : null,
215
+ ...(any ? { any } : {}),
216
+ });
217
+ };
218
+ for (const l of links || []) {
219
+ if ((l.access !== 'write' && l.access !== 'admin') || !under(cls.path, l.id))
220
+ continue;
221
+ if (l.relation === 'admin')
222
+ add(l, null, true);
223
+ else if (l.relation !== 'member')
224
+ continue;
225
+ else if (l.place)
226
+ add(l, l.place);
227
+ else if (l.places?.length)
228
+ l.places.forEach(p => add(l, p));
229
+ else
230
+ add(l, null);
231
+ }
232
+ return [...out.values()].sort((a, b) => a.unit_label.localeCompare(b.unit_label, 'ru')
233
+ || String(a.place_label ?? '').localeCompare(String(b.place_label ?? ''), 'ru'));
234
+ }
235
+
236
+ const ctxTitle = c => (c.place ? `${c.unit_label} / ${c.place_label}` : c.unit_label);
237
+
238
+ /**
239
+ * Подразделение и место записи объекта. Не указаны — единственные доступные; доступных
240
+ * несколько — ошибка с их списком. Указанные проверяются по местам записи пользователя.
241
+ * @param {Array} contexts writeContexts
242
+ * @param {object} want { unit?, place? } — что передал клиент
243
+ * @param {object} [opts] { full — запись без ссылок (назначение класса, системная сессия):
244
+ * любое подразделение; known — пути мест указанного подразделения (проверка `place`) }
245
+ * @returns {{ unit: string|null, place: string|null }}
246
+ */
247
+ export function resolvePlace(db, contexts, { unit, place } = {}, { full = false, known = null } = {}) {
248
+ const pl = isEmpty(place) ? null : String(place);
249
+ if (isEmpty(unit)) {
250
+ if (pl)
251
+ throw new DataError('место (place) указывается вместе с подразделением (unit)');
252
+ if (contexts.length === 1)
253
+ return { unit: contexts[0].unit, place: contexts[0].place };
254
+ if (full)
255
+ return { unit: null, place: null };
256
+ throw new DataError(contexts.length
257
+ ? `укажите подразделение и место записи: ${contexts.map(ctxTitle).join('; ')}`
258
+ : 'нет места, от которого можно записать в этот класс');
259
+ }
260
+ const c = getClass(db, unit);
261
+ if (!c || FAMILY[c.type] !== 'structure')
262
+ throw new DataError(`подразделение ${unit} не найдено`);
263
+ if (pl && !(known || []).includes(pl))
264
+ throw new DataError(`место ${pl} не найдено в «${c.label}»`);
265
+ const mine = contexts.filter(x => x.unit === c.cid);
266
+ if (!mine.length) {
267
+ if (full)
268
+ return { unit: c.cid, place: pl };
269
+ throw new DataError(`нет права записи от подразделения «${c.label}»`);
270
+ }
271
+ if (pl) {
272
+ if (full || mine.some(x => x.any || x.place === pl))
273
+ return { unit: c.cid, place: pl };
274
+ throw new DataError(`нет права записи от места «${pl}» подразделения «${c.label}»`);
275
+ }
276
+ if (mine.length === 1)
277
+ return { unit: c.cid, place: mine[0].place };
278
+ if (full || mine.some(x => x.any))
279
+ return { unit: c.cid, place: null };
280
+ throw new DataError(`укажите место записи в «${c.label}»: ${mine.map(ctxTitle).join('; ')}`);
281
+ }