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,1033 @@
1
+ /**
2
+ * Отчёты единого журнала: журнал проводок, оборотно-сальдовая ведомость,
3
+ * карточка счёта, состояние и траектория аналитического объекта, состав
4
+ * виртуального справочника на дату.
5
+ *
6
+ * Отчёты строятся только по проводкам (docs/architecture-v2.md, Р10): у черновика
7
+ * проводок нет, у отменённой операции проводки и сторно взаимно гасятся.
8
+ * Итог по счёту включает все его субсчета (потомки в class_tree).
9
+ *
10
+ * Суммы — в рублях (в БД — копейки); сальдо делится на дебетовое и кредитовое,
11
+ * количества — со знаком (Дт − Кт) в базовой единице.
12
+ * Период — [from, to): граница-день в `to` включает весь день. Момент `at`
13
+ * («состояние на») включителен; день 'ГГГГ-ММ-ДД' — на конец дня.
14
+ * Общие фильтры проводок: `units` (список подразделений) или `unit`
15
+ * (подразделение с вложенными), `op_cid` (класс операций с потомками), `op`, `author`, `place`;
16
+ * `visible` — права видимости (scope.js, `entryGrants`): у ведомости, карточки и объекта ветка-счета
17
+ * проверяется по счёту своей стороны, у журнала — по любой стороне.
18
+ * Названия объектов и классов — в `names` ответа (текущие).
19
+ */
20
+ import { getClass, descendants, ancestors, classesOfType } from './classes.js';
21
+ import { classSchema, analyticSlots, objectTitle, FAMILY } from './schema.js';
22
+ import { readObject, queryObjects, linkCids } from './objects.js';
23
+ import { presentEntry } from './posting.js';
24
+ import { round } from './expr.js';
25
+ import { DataError } from './errors.js';
26
+ import { parseTime, periodRange, dayStart, nextDay, localDay } from './time.js';
27
+ import { entryCond } from './scope.js';
28
+
29
+ const isEmpty = v => v === undefined || v === null || v === '';
30
+ const LOW = -8.64e15, HIGH = 8.64e15;
31
+ const lo = x => (Number.isFinite(x) ? x : LOW);
32
+ const hi = x => (Number.isFinite(x) ? x : HIGH);
33
+ const rub = k => k / 100;
34
+ const q6 = v => round(v, 6);
35
+ const CHUNK = 500;
36
+
37
+ /** Стороны проводки: [сторона, колонка счёта, корр. колонка, знак суммы]. */
38
+ const SIDES = [['D', 'debit_cid', 'credit_cid', ''], ['C', 'credit_cid', 'debit_cid', '-']];
39
+
40
+ function inCond(col, list) {
41
+ const values = [...new Set([].concat(list).map(String))];
42
+ return values.length ? [`${col} IN (${values.map(() => '?').join(',')})`, values] : ['0', []];
43
+ }
44
+
45
+ /** Исключающая верхняя граница момента «на»: день — до конца дня, момент — включительно. */
46
+ function atBound(at) {
47
+ if (isEmpty(at))
48
+ return HIGH;
49
+ const s = String(at).trim();
50
+ if (/^\d{4}-\d{2}-\d{2}$/.test(s))
51
+ return nextDay(dayStart(s));
52
+ return parseTime(at, 'момент') + 1;
53
+ }
54
+
55
+ function page(q, def, max) {
56
+ return {
57
+ limit: Math.max(1, Math.min(max, Number(q.limit) || def)),
58
+ offset: Math.max(0, Number(q.offset) || 0),
59
+ };
60
+ }
61
+
62
+ function mustClass(db, ref, family, what) {
63
+ const c = getClass(db, ref);
64
+ if (!c || FAMILY[c.type] !== family)
65
+ throw new DataError(`${what} ${ref} не найден`);
66
+ return c;
67
+ }
68
+
69
+ /** Общие условия на проводки (алиас таблицы — e): видимость, класс операций, операция, автор, место. */
70
+ function entryConds(db, q) {
71
+ const c = [];
72
+ if (Array.isArray(q.units))
73
+ c.push(inCond('e.unit', q.units));
74
+ if (!isEmpty(q.unit))
75
+ c.push(inCond('e.unit', descendants(db, mustClass(db, q.unit, 'structure', 'подразделение').cid)));
76
+ if (!isEmpty(q.op_cid))
77
+ c.push(inCond('e.op_cid', descendants(db, mustClass(db, q.op_cid, 'operation', 'класс операций').cid)));
78
+ if (!isEmpty(q.op))
79
+ c.push(['e.op_id = ?', [String(q.op)]]);
80
+ if (!isEmpty(q.author))
81
+ c.push(['e.author = ?', [String(q.author)]]);
82
+ if (!isEmpty(q.place))
83
+ c.push(['e.place = ?', [String(q.place)]]);
84
+ return c;
85
+ }
86
+
87
+ /**
88
+ * Строки сторон проводок: проводка даёт строку дебета (сумма со знаком +) и кредита (−).
89
+ * Колонки: side, id, time, op_id, op_cid, rule, row, storno, acc, corr, amt, q, q2,
90
+ * slot (слот объекта при `object`), k0..kN (аналитики стороны по слотам `by`).
91
+ * @param {object} p { accounts?: cid[], by?: слоты, analytics?: { слот: id }, object?: id, conds?: [sql, args][],
92
+ * visible?: права видимости — ветка-счета по счёту стороны }
93
+ */
94
+ function sideRows({ accounts = null, by = [], analytics = null, object = null, conds = [], visible = null }) {
95
+ const parts = [], args = [];
96
+ for (const [side, col, other, sign] of SIDES) {
97
+ let sql = `SELECT '${side}' side, e.id, e.time, e.op_id, e.op_cid, e.rule, e.row, e.storno,
98
+ e.${col} acc, e.${other} corr, ${sign}e.amount amt, ${sign}e.qty q, ${sign}e.qty2 q2,
99
+ ${object ? 'o.slot' : 'NULL'} slot${by.map((_, i) => `, k${i}.ref_id k${i}`).join('')}
100
+ FROM entries e`;
101
+ if (object) {
102
+ sql += ` JOIN entry_analytics o ON o.entry_id = e.id AND o.side = '${side}' AND o.ref_id = ?`;
103
+ args.push(String(object));
104
+ }
105
+ by.forEach((slot, i) => {
106
+ sql += ` LEFT JOIN entry_analytics k${i} ON k${i}.entry_id = e.id AND k${i}.side = '${side}' AND k${i}.slot = ?`;
107
+ args.push(slot);
108
+ });
109
+ const where = [];
110
+ const add = ([s, a]) => {
111
+ where.push(s);
112
+ args.push(...a);
113
+ };
114
+ if (accounts)
115
+ add(inCond(`e.${col}`, accounts));
116
+ for (const [slot, ref] of Object.entries(analytics || {})) {
117
+ if (Array.isArray(ref)) {
118
+ if (!ref.length)
119
+ add(['0', []]);
120
+ else
121
+ add([`EXISTS (SELECT 1 FROM entry_analytics f WHERE f.entry_id = e.id AND f.side = '${side}' AND f.slot = ? AND f.ref_id IN (${ref.map(() => '?').join(',')}))`,
122
+ [String(slot), ...ref.map(String)]]);
123
+ }
124
+ else if (!isEmpty(ref)) {
125
+ add([`EXISTS (SELECT 1 FROM entry_analytics f WHERE f.entry_id = e.id AND f.side = '${side}' AND f.slot = ? AND f.ref_id = ?)`,
126
+ [String(slot), String(typeof ref === 'object' ? ref.id : ref)]]);
127
+ }
128
+ }
129
+ conds.forEach(add);
130
+ const vc = entryCond(visible, [`e.${col}`]);
131
+ if (vc)
132
+ add(vc);
133
+ if (where.length)
134
+ sql += ' WHERE ' + where.join(' AND ');
135
+ parts.push(sql);
136
+ }
137
+ return { sql: parts.join(' UNION ALL '), args };
138
+ }
139
+
140
+ /** Аналитики проводок: id → { D: { слот: id }, C: {...} }. */
141
+ function analyticsMap(db, ids) {
142
+ const map = new Map();
143
+ const list = [...new Set(ids)];
144
+ for (let i = 0; i < list.length; i += CHUNK) {
145
+ const chunk = list.slice(i, i + CHUNK);
146
+ for (const a of db.prepare(`SELECT entry_id, side, slot, ref_id FROM entry_analytics WHERE entry_id IN (${chunk.map(() => '?').join(',')})`).all(...chunk)) {
147
+ if (!map.has(a.entry_id))
148
+ map.set(a.entry_id, { D: {}, C: {} });
149
+ map.get(a.entry_id)[a.side][a.slot] = a.ref_id;
150
+ }
151
+ }
152
+ return id => map.get(id) ?? { D: {}, C: {} };
153
+ }
154
+
155
+ /** Названия для показа: объекты (текущий заголовок) и классы (label); пути классов — чтобы форма открыла класс. */
156
+ export function namesOf(db, ids, cids = []) {
157
+ const objects = {}, classes = {}, paths = {};
158
+ const list = [...new Set(ids)].filter(v => !isEmpty(v)).map(String);
159
+ for (let i = 0; i < list.length; i += CHUNK) {
160
+ const chunk = list.slice(i, i + CHUNK);
161
+ for (const r of db.prepare(`SELECT id, cid, body FROM objects WHERE id IN (${chunk.map(() => '?').join(',')})`).all(...chunk))
162
+ objects[r.id] = objectTitle(classSchema(db, r.cid), JSON.parse(r.body)) || r.id;
163
+ }
164
+ for (const cid of new Set(cids)) {
165
+ if (isEmpty(cid))
166
+ continue;
167
+ const c = getClass(db, cid);
168
+ classes[cid] = c?.label ?? cid;
169
+ if (c)
170
+ paths[cid] = c.path;
171
+ }
172
+ return { objects, classes, paths };
173
+ }
174
+
175
+ /** Сальдо из суммы (копейки, Дт − Кт) и количеств. */
176
+ function balance(a, q = 0, q2 = 0) {
177
+ return { debit: rub(Math.max(a, 0)), credit: rub(Math.max(-a, 0)), qty: q6(q), qty2: q6(q2) };
178
+ }
179
+
180
+ function accountCids(db, ref) {
181
+ return descendants(db, mustClass(db, ref, 'account', 'счёт').cid);
182
+ }
183
+
184
+ /**
185
+ * Собрать определения слотов аналитик по списку счетов (слот → схема целевого справочника).
186
+ */
187
+ function slotsOfAccounts(db, accCids) {
188
+ const map = new Map();
189
+ const list = accCids?.length ? accCids : classesOfType(db, '$account').map(c => c.cid);
190
+ for (const cid of list) {
191
+ const s = classSchema(db, cid);
192
+ for (const f of analyticSlots(s || { fields: [] })) {
193
+ if (!map.has(f.id) && f.target) {
194
+ const targetSchema = classSchema(db, f.target);
195
+ if (targetSchema)
196
+ map.set(f.id, targetSchema);
197
+ }
198
+ }
199
+ }
200
+ return map;
201
+ }
202
+
203
+ /**
204
+ * Двухэтапный механизм аналитик (субаналитики):
205
+ * 1) Если задан фильтр по полям справочника-аналитики (через `analytics: { 'слот.поле': знач }`,
206
+ * `analytics: { слот: { поле: знач } }` или `subanalytics: { слот: { поле: знач } }`),
207
+ * сначала отбираются id подходящих объектов из справочника и его расширения OVERLAY ($virtual).
208
+ * 2) Возвращается словарь `{ слот: id | id[] }` для фильтрации проводок счёта.
209
+ */
210
+ export function resolveAnalyticsFilter(db, accCids, analytics, subanalytics) {
211
+ if (!analytics && !subanalytics)
212
+ return null;
213
+ const slotTargets = slotsOfAccounts(db, accCids);
214
+ const direct = new Map();
215
+ const subConds = new Map();
216
+ const addSub = (slot, field, cond) => {
217
+ if (isEmpty(cond))
218
+ return;
219
+ if (!subConds.has(slot))
220
+ subConds.set(slot, {});
221
+ subConds.get(slot)[field] = cond;
222
+ };
223
+
224
+ for (const [k, v] of Object.entries(analytics || {})) {
225
+ if (isEmpty(v))
226
+ continue;
227
+ const dot = k.indexOf('.');
228
+ if (dot > 0) {
229
+ addSub(k.slice(0, dot), k.slice(dot + 1), v);
230
+ continue;
231
+ }
232
+ if (Array.isArray(v)) {
233
+ direct.set(k, v.map(x => String(typeof x === 'object' ? x.id : x)));
234
+ continue;
235
+ }
236
+ if (typeof v === 'object' && !('id' in v)) {
237
+ const w = v.where && typeof v.where === 'object' ? v.where : v;
238
+ for (const [fk, fv] of Object.entries(w))
239
+ addSub(k, fk, fv);
240
+ continue;
241
+ }
242
+ direct.set(k, String(typeof v === 'object' ? v.id : v));
243
+ }
244
+ for (const [slot, condObj] of Object.entries(subanalytics || {})) {
245
+ if (!condObj || typeof condObj !== 'object')
246
+ continue;
247
+ for (const [fk, fv] of Object.entries(condObj))
248
+ addSub(slot, fk, fv);
249
+ }
250
+
251
+ const out = {};
252
+ for (const [slot, val] of direct)
253
+ out[slot] = val;
254
+
255
+ for (const [slot, where] of subConds) {
256
+ if (!Object.keys(where).length)
257
+ continue;
258
+ const target = slotTargets.get(slot);
259
+ if (!target) {
260
+ out[slot] = [];
261
+ continue;
262
+ }
263
+ const overlayFieldIds = new Set((target.overlay || []).map(f => f.id));
264
+ const regWhere = {}, overWhere = {};
265
+ for (const [fk, fv] of Object.entries(where)) {
266
+ if (target.type === '$virtual' && overlayFieldIds.has(fk))
267
+ overWhere[fk] = fv;
268
+ else
269
+ regWhere[fk] = fv;
270
+ }
271
+ let ids = null;
272
+ if (Object.keys(regWhere).length) {
273
+ const regCid = target.type === '$virtual' ? target.source?.registry : target.cid;
274
+ const found = regCid ? queryObjects(db, { cid: regCid, where: regWhere, limit: 500 }).items.map(o => o.id) : [];
275
+ ids = new Set(found);
276
+ }
277
+ if (Object.keys(overWhere).length) {
278
+ const exts = queryObjects(db, { cid: target.cid, where: overWhere, limit: 500 }).items
279
+ .map(o => o.ext_of).filter(Boolean);
280
+ ids = ids ? new Set(exts.filter(id => ids.has(id))) : new Set(exts);
281
+ }
282
+ if (direct.has(slot)) {
283
+ const d = [].concat(direct.get(slot));
284
+ ids = ids ? new Set(d.filter(id => ids.has(id))) : new Set(d);
285
+ }
286
+ out[slot] = ids ? [...ids] : [];
287
+ }
288
+ return Object.keys(out).length ? out : null;
289
+ }
290
+
291
+ /** Значение поля субаналитики (`поле` или `структура.поле` или поле OVERLAY) у объекта id. */
292
+ function readSubAnalyticValue(db, targetSchema, objId, fieldPath) {
293
+ if (!objId)
294
+ return null;
295
+ const obj = readObject(db, objId);
296
+ if (!obj)
297
+ return null;
298
+ if (targetSchema?.type === '$virtual' && (targetSchema.overlay || []).some(f => f.id === fieldPath)) {
299
+ const extRow = db.prepare("SELECT body FROM objects WHERE cid = ? AND ext_of = ? AND status <> 'deleted' LIMIT 1").get(targetSchema.cid, objId);
300
+ if (extRow) {
301
+ const extBody = JSON.parse(extRow.body);
302
+ if (extBody[fieldPath] !== undefined && extBody[fieldPath] !== null && extBody[fieldPath] !== '')
303
+ return String(extBody[fieldPath]);
304
+ }
305
+ }
306
+ let cur = obj.body;
307
+ for (const part of fieldPath.split('.')) {
308
+ if (!cur || typeof cur !== 'object')
309
+ return null;
310
+ cur = cur[part];
311
+ }
312
+ return cur === undefined || cur === null || cur === '' ? null : String(cur);
313
+ }
314
+
315
+ function rangeOfTimes(rows) {
316
+ if (!rows?.length)
317
+ return null;
318
+ const days = [...new Set(rows.map(r => localDay(r.time)).filter(Boolean))].sort();
319
+ return days.length ? { start: days[0], end: days[days.length - 1], days } : null;
320
+ }
321
+
322
+ /* ---------- журнал ---------- */
323
+
324
+ /**
325
+ * Журнал проводок за период.
326
+ * @param {object} [q]
327
+ * @param {*} [q.from] Начало периода
328
+ * @param {*} [q.to] Конец периода (день — включительно)
329
+ * @param {string} [q.account] Счёт (с субсчетами) в дебете или кредите
330
+ * @param {string} [q.debit] Счёт дебета (с субсчетами)
331
+ * @param {string} [q.credit] Счёт кредита (с субсчетами)
332
+ * @param {string} [q.object] Объект в аналитике любой стороны
333
+ * @param {boolean} [q.effective] Только действующие проводки: без сторно и прежних версий проведения
334
+ * @param {boolean} [q.desc] Новые сверху
335
+ * @param {number} [q.limit=100]
336
+ * @param {number} [q.offset=0]
337
+ * @returns {{ total, sum: {amount, qty, qty2}, items: object[], names, range }}
338
+ */
339
+ export function journal(db, q = {}) {
340
+ const [from, to] = periodRange(q.from, q.to);
341
+ const baseConds = entryConds(db, q);
342
+ if (!isEmpty(q.account)) {
343
+ const cids = accountCids(db, q.account);
344
+ const [d, da] = inCond('e.debit_cid', cids), [c, ca] = inCond('e.credit_cid', cids);
345
+ baseConds.push([`(${d} OR ${c})`, [...da, ...ca]]);
346
+ }
347
+ if (!isEmpty(q.debit))
348
+ baseConds.push(inCond('e.debit_cid', accountCids(db, q.debit)));
349
+ if (!isEmpty(q.credit))
350
+ baseConds.push(inCond('e.credit_cid', accountCids(db, q.credit)));
351
+ if (!isEmpty(q.object))
352
+ baseConds.push(['EXISTS (SELECT 1 FROM entry_analytics f WHERE f.entry_id = e.id AND f.ref_id = ?)', [String(q.object)]]);
353
+ if (q.effective)
354
+ baseConds.push(['e.storno = 0 AND NOT EXISTS (SELECT 1 FROM entries x WHERE x.op_id = e.op_id AND x.op_version > e.op_version)', []]);
355
+ const visible = entryCond(q.visible);
356
+ if (visible)
357
+ baseConds.push(visible);
358
+ const baseWhere = baseConds.length ? 'WHERE ' + baseConds.map(c => c[0]).join(' AND ') : '';
359
+ const baseArgs = baseConds.flatMap(c => c[1]);
360
+ const range = rangeOfTimes(db.prepare(`SELECT DISTINCT e.time AS time FROM entries e ${baseWhere}`).all(...baseArgs));
361
+
362
+ const conds = [...baseConds];
363
+ if (from > -Infinity)
364
+ conds.push(['e.time >= ?', [from]]);
365
+ if (to < Infinity)
366
+ conds.push(['e.time < ?', [to]]);
367
+ const where = conds.length ? 'WHERE ' + conds.map(c => c[0]).join(' AND ') : '';
368
+ const args = conds.flatMap(c => c[1]);
369
+ const t = db.prepare(`SELECT count(*) n, coalesce(sum(e.amount), 0) a, coalesce(sum(e.qty), 0) q, coalesce(sum(e.qty2), 0) q2
370
+ FROM entries e ${where}`).get(...args);
371
+ const { limit, offset } = page(q, 100, 1000);
372
+ const dir = q.desc ? 'DESC' : 'ASC';
373
+ const rows = db.prepare(`SELECT e.* FROM entries e ${where} ORDER BY e.time ${dir}, e.id ${dir} LIMIT ? OFFSET ?`)
374
+ .all(...args, limit, offset);
375
+ const an = analyticsMap(db, rows.map(r => r.id));
376
+ const items = rows.map(r => {
377
+ const a = an(r.id);
378
+ const ref = side => Object.fromEntries(Object.entries(side).map(([slot, id]) => [slot, { ref_id: id }]));
379
+ return presentEntry({ ...r, analytics: { D: ref(a.D), C: ref(a.C) } });
380
+ });
381
+ return {
382
+ total: t.n,
383
+ sum: { amount: rub(t.a), qty: q6(t.q), qty2: q6(t.q2) },
384
+ items,
385
+ range,
386
+ names: namesOf(db,
387
+ items.flatMap(e => [e.op_id, ...Object.values(e.debit_analytics), ...Object.values(e.credit_analytics)]),
388
+ items.flatMap(e => [e.debit, e.credit, e.op_cid])),
389
+ };
390
+ }
391
+
392
+ /* ---------- оборотно-сальдовая ведомость ---------- */
393
+
394
+ const ZERO = Object.freeze({ od: 0, oc: 0, td: 0, tc: 0, cd: 0, cc: 0, qo: 0, qd: 0, qc: 0, wo: 0, wd: 0, wc: 0 });
395
+
396
+ /** Итог строки ведомости (копейки): сальдо развёрнуто на дебетовое и кредитовое. */
397
+ function cellTotals(r) {
398
+ const close = r.oa + r.da - r.ca;
399
+ return {
400
+ od: Math.max(r.oa, 0), oc: Math.max(-r.oa, 0), td: r.da, tc: r.ca, cd: Math.max(close, 0), cc: Math.max(-close, 0),
401
+ qo: r.oq, qd: r.dq, qc: r.cq, wo: r.oq2, wd: r.dq2, wc: r.cq2,
402
+ };
403
+ }
404
+
405
+ function addTotals(t, x) {
406
+ for (const k of Object.keys(ZERO))
407
+ t[k] += x[k];
408
+ return t;
409
+ }
410
+
411
+ const isZero = t => Object.keys(ZERO).every(k => Math.abs(t[k]) < 1e-9);
412
+
413
+ function presentTotals(t) {
414
+ const qty = (o, d, c) => ({ opening: q6(o), debit: q6(d), credit: q6(c), closing: q6(o + d - c) });
415
+ return {
416
+ opening: { debit: rub(t.od), credit: rub(t.oc) },
417
+ turnover: { debit: rub(t.td), credit: rub(t.tc) },
418
+ closing: { debit: rub(t.cd), credit: rub(t.cc) },
419
+ qty: qty(t.qo, t.qd, t.qc),
420
+ qty2: qty(t.wo, t.wd, t.wc),
421
+ };
422
+ }
423
+
424
+ const byPath = (a, b) => a.path.localeCompare(b.path, 'ru', { numeric: true });
425
+
426
+ /** Счета ведомости: все счета или счёт (журнал) с потомками; узлы с детьми. */
427
+ function accountTree(db, ref) {
428
+ let list = classesOfType(db, '$account');
429
+ if (!isEmpty(ref)) {
430
+ const set = new Set(accountCids(db, ref));
431
+ list = list.filter(c => set.has(c.cid));
432
+ }
433
+ const nodes = new Map(list.map(c => [c.cid, { cid: c.cid, label: c.label, path: c.path, parent: c.parent, children: [], details: [], own: null }]));
434
+ for (const n of nodes.values())
435
+ nodes.get(n.parent)?.children.push(n);
436
+ return nodes;
437
+ }
438
+
439
+ /**
440
+ * Оборотно-сальдовая ведомость за период: сальдо на начало, обороты, сальдо на конец
441
+ * по счетам и их субсчетам (итог счёта — сумма субсчетов, сальдо развёрнутое).
442
+ * @param {object} [q]
443
+ * @param {*} [q.from] Начало периода
444
+ * @param {*} [q.to] Конец периода (день — включительно)
445
+ * @param {string} [q.account] Только этот счёт (или журнал) с субсчетами
446
+ * @param {string|string[]} [q.by] Слоты аналитик для разворота строк счёта (например 'counterparty')
447
+ * @returns {{ rows: object[], total: object, names }}
448
+ * строка: { account, label, level, parent, leaf, key?: { слот: id }, opening, turnover, closing, qty, qty2 }
449
+ */
450
+ export function trialBalance(db, q = {}) {
451
+ const [from, to] = periodRange(q.from, q.to);
452
+ const by = [].concat(q.by ?? []).filter(s => !isEmpty(s)).map(String);
453
+ const baseSlots = by.map(s => s.split('.')[0]);
454
+ const nodes = accountTree(db, q.account);
455
+ const accList = isEmpty(q.account) ? null : [...nodes.keys()];
456
+ const slotTargets = slotsOfAccounts(db, accList);
457
+ const resolvedAnalytics = resolveAnalyticsFilter(db, accList, q.analytics, q.subanalytics);
458
+ const baseConds = entryConds(db, q);
459
+ const baseSide = sideRows({ accounts: accList, analytics: resolvedAnalytics, conds: baseConds, visible: q.visible });
460
+ const range = rangeOfTimes(db.prepare(`SELECT DISTINCT time FROM (${baseSide.sql})`).all(...baseSide.args));
461
+ const conds = [...baseConds, ['e.time < ?', [hi(to)]]];
462
+ const { sql, args } = sideRows({ accounts: accList, by: baseSlots, analytics: resolvedAnalytics, conds, visible: q.visible });
463
+ const keys = baseSlots.map((_, i) => `k${i}`);
464
+ const F = lo(from);
465
+ const sums = (col, cond) => `coalesce(sum(CASE WHEN ${cond} THEN ${col} END), 0)`;
466
+ const open = 'time < ?', debit = "time >= ? AND side = 'D'", credit = "time >= ? AND side = 'C'";
467
+ const rows = db.prepare(`SELECT acc${keys.map(k => ', ' + k).join('')},
468
+ ${sums('amt', open)} oa, ${sums('q', open)} oq, ${sums('q2', open)} oq2,
469
+ ${sums('amt', debit)} da, ${sums('q', debit)} dq, ${sums('q2', debit)} dq2,
470
+ -${sums('amt', credit)} ca, -${sums('q', credit)} cq, -${sums('q2', credit)} cq2
471
+ FROM (${sql}) GROUP BY acc${keys.map(k => ', ' + k).join('')}`)
472
+ .all(...Array(9).fill(F), ...args);
473
+
474
+ for (const r of rows) {
475
+ let node = nodes.get(r.acc);
476
+ if (!node) {
477
+ node = { cid: r.acc, label: r.acc, path: r.acc, parent: null, children: [], details: [], own: null, orphan: true };
478
+ nodes.set(r.acc, node);
479
+ }
480
+ const key = by.length ? Object.fromEntries(by.map((spec, i) => {
481
+ const rawId = r[`k${i}`] ?? null;
482
+ const dot = spec.indexOf('.');
483
+ if (dot < 0 || !rawId)
484
+ return [spec, rawId];
485
+ const slot = spec.slice(0, dot), subField = spec.slice(dot + 1);
486
+ return [spec, readSubAnalyticValue(db, slotTargets.get(slot), rawId, subField)];
487
+ })) : null;
488
+ if (key) {
489
+ const sig = JSON.stringify(key);
490
+ const existing = node.details.find(d => d._sig === sig);
491
+ if (existing) {
492
+ for (const k of ['oa', 'oq', 'oq2', 'da', 'dq', 'dq2', 'ca', 'cq', 'cq2'])
493
+ existing._raw[k] += r[k];
494
+ existing.totals = cellTotals(existing._raw);
495
+ continue;
496
+ }
497
+ const rawCopy = { oa: r.oa, oq: r.oq, oq2: r.oq2, da: r.da, dq: r.dq, dq2: r.dq2, ca: r.ca, cq: r.cq, cq2: r.cq2 };
498
+ node.details.push({ key, _sig: sig, _raw: rawCopy, totals: cellTotals(rawCopy) });
499
+ }
500
+ else {
501
+ node.details.push({ key: null, totals: cellTotals(r) });
502
+ }
503
+ }
504
+ const keyIds = [];
505
+ for (const n of nodes.values()) {
506
+ n.keyed = by.length > 0 && n.details.some(d => Object.values(d.key).some(v => v !== null));
507
+ if (!n.keyed && n.details.length) {
508
+ const r = { oa: 0, oq: 0, oq2: 0, da: 0, dq: 0, dq2: 0, ca: 0, cq: 0, cq2: 0 };
509
+ for (const d of n.details) {
510
+ const t = d.totals;
511
+ r.oa += t.od - t.oc; r.da += t.td; r.ca += t.tc;
512
+ r.oq += t.qo; r.dq += t.qd; r.cq += t.qc;
513
+ r.oq2 += t.wo; r.dq2 += t.wd; r.cq2 += t.wc;
514
+ }
515
+ n.own = cellTotals(r);
516
+ n.details = [];
517
+ }
518
+ else if (n.keyed)
519
+ keyIds.push(...n.details.flatMap(d => Object.values(d.key)));
520
+ }
521
+ const names = namesOf(db, keyIds, [...nodes.keys()]);
522
+ const title = key => Object.values(key).map(id => (id === null ? '' : names.objects[id] ?? id)).join(' / ');
523
+
524
+ const out = [];
525
+ const walk = (n, level) => {
526
+ const t = { ...ZERO };
527
+ if (n.own)
528
+ addTotals(t, n.own);
529
+ for (const d of n.details)
530
+ addTotals(t, d.totals);
531
+ const children = [...n.children].sort(byPath).map(c => walk(c, level + 1));
532
+ for (const c of children)
533
+ addTotals(t, c.t);
534
+ const ownSlots = analyticSlots(classSchema(db, n.cid) || { fields: [] });
535
+ const hasAnalytics = ownSlots.length > 0 || children.some(c => c.hasAnalytics);
536
+ return { n, level, t, children, hasAnalytics };
537
+ };
538
+ const emit = ({ n, level, t, children, hasAnalytics }) => {
539
+ if (isZero(t))
540
+ return;
541
+ out.push({ account: n.cid, label: n.label, level, parent: nodes.has(n.parent) ? n.parent : null, leaf: !n.children.length, hasAnalytics: !!hasAnalytics, ...presentTotals(t) });
542
+ for (const d of [...n.details].sort((a, b) => title(a.key).localeCompare(title(b.key), 'ru'))) {
543
+ if (!isZero(d.totals))
544
+ out.push({ account: n.cid, label: n.label, level: level + 1, parent: n.cid, leaf: true, key: d.key, hasAnalytics: true, ...presentTotals(d.totals) });
545
+ }
546
+ children.forEach(emit);
547
+ };
548
+ const roots = [...nodes.values()].filter(n => !nodes.has(n.parent)).sort(byPath).map(n => walk(n, 0));
549
+ const total = { ...ZERO };
550
+ for (const r of roots) {
551
+ addTotals(total, r.t);
552
+ emit(r);
553
+ }
554
+ return { rows: out, total: presentTotals(total), range, names };
555
+ }
556
+
557
+ /* ---------- карточка счёта ---------- */
558
+
559
+ function sideItem(r, an, opening) {
560
+ const a = an(r.id);
561
+ const own = r.side === 'D' ? a.D : a.C, corr = r.side === 'D' ? a.C : a.D;
562
+ return {
563
+ entry: r.id,
564
+ time: r.time,
565
+ op_id: r.op_id,
566
+ op_cid: r.op_cid,
567
+ rule: r.rule,
568
+ row: r.row,
569
+ storno: !!r.storno,
570
+ account: r.acc,
571
+ ...(r.slot ? { slot: r.slot } : {}),
572
+ side: r.side === 'D' ? 'debit' : 'credit',
573
+ corr: r.corr,
574
+ debit: r.side === 'D' ? rub(r.amt) : 0,
575
+ credit: r.side === 'C' ? rub(-r.amt) : 0,
576
+ qty: q6(r.q),
577
+ qty2: q6(r.q2),
578
+ analytics: own,
579
+ corr_analytics: corr,
580
+ balance: rub(opening.a + r.ba),
581
+ balance_qty: q6(opening.q + r.bq),
582
+ balance_qty2: q6(opening.q2 + r.bq2),
583
+ };
584
+ }
585
+
586
+ function itemNames(db, items, extra = [], extraCids = []) {
587
+ return namesOf(db,
588
+ [...extra, ...items.flatMap(i => [i.op_id, ...Object.values(i.analytics), ...Object.values(i.corr_analytics)])],
589
+ [...extraCids, ...items.flatMap(i => [i.account, i.corr, i.op_cid])]);
590
+ }
591
+
592
+ /**
593
+ * Карточка счёта: сальдо на начало, проводки периода со стороной, корр. счётом
594
+ * и сальдо после каждой, обороты и сальдо на конец.
595
+ * @param {object} q
596
+ * @param {string} q.account Счёт (с субсчетами)
597
+ * @param {object} [q.analytics] Отбор по аналитикам стороны счёта: { слот: id }
598
+ * @param {*} [q.from] Начало периода
599
+ * @param {*} [q.to] Конец периода (день — включительно)
600
+ * @param {number} [q.limit=500]
601
+ * @param {number} [q.offset=0]
602
+ */
603
+ export function accountCard(db, q = {}) {
604
+ if (isEmpty(q.account))
605
+ throw new DataError('карточка счёта: не задан счёт');
606
+ const cls = mustClass(db, q.account, 'account', 'счёт');
607
+ const [from, to] = periodRange(q.from, q.to);
608
+ const baseConds = entryConds(db, q);
609
+ const accs = descendants(db, cls.cid);
610
+ const resolvedAnalytics = resolveAnalyticsFilter(db, accs, q.analytics, q.subanalytics);
611
+ const baseSide = sideRows({ accounts: accs, analytics: resolvedAnalytics, conds: baseConds, visible: q.visible });
612
+ const range = rangeOfTimes(db.prepare(`SELECT DISTINCT time FROM (${baseSide.sql})`).all(...baseSide.args));
613
+ const conds = [...baseConds, ['e.time < ?', [hi(to)]]];
614
+ const { sql, args } = sideRows({ accounts: accs, analytics: resolvedAnalytics, conds, visible: q.visible });
615
+ const F = lo(from);
616
+ const o = db.prepare(`SELECT coalesce(sum(amt), 0) a, coalesce(sum(q), 0) q, coalesce(sum(q2), 0) q2 FROM (${sql}) WHERE time < ?`)
617
+ .get(...args, F);
618
+ const t = db.prepare(`SELECT count(*) n,
619
+ coalesce(sum(CASE WHEN side = 'D' THEN amt END), 0) da, -coalesce(sum(CASE WHEN side = 'C' THEN amt END), 0) ca,
620
+ coalesce(sum(CASE WHEN side = 'D' THEN q END), 0) dq, -coalesce(sum(CASE WHEN side = 'C' THEN q END), 0) cq,
621
+ coalesce(sum(CASE WHEN side = 'D' THEN q2 END), 0) dq2, -coalesce(sum(CASE WHEN side = 'C' THEN q2 END), 0) cq2
622
+ FROM (${sql}) WHERE time >= ?`).get(...args, F);
623
+ const { limit, offset } = page(q, 500, 5000);
624
+ const rows = db.prepare(`SELECT *, sum(amt) OVER w ba, sum(q) OVER w bq, sum(q2) OVER w bq2 FROM (${sql}) WHERE time >= ?
625
+ WINDOW w AS (ORDER BY time, id, side ROWS UNBOUNDED PRECEDING) ORDER BY time, id, side LIMIT ? OFFSET ?`)
626
+ .all(...args, F, limit, offset);
627
+ const an = analyticsMap(db, rows.map(r => r.id));
628
+ const items = rows.map(r => sideItem(r, an, o));
629
+ const extraIds = Object.values(resolvedAnalytics || {}).flat().filter(v => typeof v === 'string');
630
+ return {
631
+ account: cls.cid,
632
+ label: cls.label,
633
+ opening: balance(o.a, o.q, o.q2),
634
+ turnover: { debit: rub(t.da), credit: rub(t.ca), qty_debit: q6(t.dq), qty_credit: q6(t.cq), qty2_debit: q6(t.dq2), qty2_credit: q6(t.cq2) },
635
+ closing: balance(o.a + t.da - t.ca, o.q + t.dq - t.cq, o.q2 + t.dq2 - t.cq2),
636
+ total: t.n,
637
+ items,
638
+ range,
639
+ names: itemNames(db, items, extraIds),
640
+ };
641
+ }
642
+
643
+ /* ---------- двухпанельные обороты счёта (Экран 3 ODANT) и сборная карточка проводки (Экран 4 ODANT) ---------- */
644
+
645
+ const MONTHS_RU = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'];
646
+
647
+ function groupBucketOf(item, groupBy, names) {
648
+ const d = new Date(item.time);
649
+ const y = d.getFullYear(), m = d.getMonth();
650
+ switch (groupBy) {
651
+ case 'day': {
652
+ const key = localDay(item.time);
653
+ return { key, label: d.toLocaleDateString('ru-RU') };
654
+ }
655
+ case 'week': {
656
+ const day = d.getDay() || 7;
657
+ const mon = new Date(y, m, d.getDate() - day + 1);
658
+ const sun = new Date(mon.getFullYear(), mon.getMonth(), mon.getDate() + 6);
659
+ const key = localDay(mon.getTime());
660
+ return { key, label: `${mon.toLocaleDateString('ru-RU')} – ${sun.toLocaleDateString('ru-RU')}` };
661
+ }
662
+ case 'month': {
663
+ const key = `${y}-${String(m + 1).padStart(2, '0')}`;
664
+ return { key, label: `${MONTHS_RU[m]} ${y} г.` };
665
+ }
666
+ case 'quarter': {
667
+ const q = Math.floor(m / 3) + 1;
668
+ return { key: `${y}-Q${q}`, label: `${q} кв. ${y} г.` };
669
+ }
670
+ case 'year':
671
+ return { key: String(y), label: `${y} г.` };
672
+ case 'operation': {
673
+ const key = item.op_cid || '';
674
+ return { key, label: names?.classes?.[key] || key.split('/').pop() || 'Операция' };
675
+ }
676
+ case 'account': {
677
+ const key = item.corr || '';
678
+ const code = key.split('/').pop();
679
+ const label = names?.classes?.[key];
680
+ return { key, label: label && label !== code ? `${code} ${label}` : code };
681
+ }
682
+ case 'none':
683
+ return { key: `${item.entry}:${item.side}`, label: `${d.toLocaleDateString('ru-RU')} · ${names?.objects?.[item.op_id] || names?.classes?.[item.op_cid] || item.op_id}` };
684
+ case 'all':
685
+ default:
686
+ return { key: 'all', label: 'За весь период' };
687
+ }
688
+ }
689
+
690
+ /**
691
+ * Обороты счёта с группировкой (Экран 3 ODANT):
692
+ * слева — сводная таблица по группам (`none|day|week|month|quarter|year|operation|account|all`) с нарастающим сальдо и оборотом (Дт − Кт);
693
+ * справа — операции выбранной группы с корреспондирующим счётом и переходом к проводке и документу.
694
+ */
695
+ export function accountTurnovers(db, q = {}) {
696
+ const groupBy = String(q.groupBy || 'quarter').toLowerCase();
697
+ const card = accountCard(db, { ...q, limit: q.limit || 2000 });
698
+ const openNet = round(card.opening.debit - card.opening.credit, 2);
699
+ const openQty = card.opening.qty;
700
+ const buckets = new Map();
701
+
702
+ const items = card.items.map(it => {
703
+ const b = groupBucketOf(it, groupBy, card.names);
704
+ let g = buckets.get(b.key);
705
+ if (!g) {
706
+ g = { key: b.key, label: b.label, debit: 0, credit: 0, qty_debit: 0, qty_credit: 0, count: 0 };
707
+ buckets.set(b.key, g);
708
+ }
709
+ g.debit = round(g.debit + it.debit, 2);
710
+ g.credit = round(g.credit + it.credit, 2);
711
+ if (it.side === 'debit')
712
+ g.qty_debit = q6(g.qty_debit + it.qty);
713
+ else
714
+ g.qty_credit = q6(g.qty_credit - it.qty);
715
+ g.count++;
716
+ return { ...it, groupKey: b.key };
717
+ });
718
+
719
+ let running = openNet;
720
+ let runningQty = openQty;
721
+ const groups = [];
722
+ for (const g of buckets.values()) {
723
+ const netTurnover = round(g.debit - g.credit, 2);
724
+ const qtyTurnover = q6(g.qty_debit - g.qty_credit);
725
+ running = round(running + netTurnover, 2);
726
+ runningQty = q6(runningQty + qtyTurnover);
727
+ groups.push({
728
+ ...g,
729
+ turnover: netTurnover,
730
+ balance: running,
731
+ balance_debit: Math.max(running, 0),
732
+ balance_credit: Math.max(-running, 0),
733
+ qty_turnover: qtyTurnover,
734
+ qty_balance: runningQty,
735
+ });
736
+ }
737
+
738
+ return {
739
+ account: card.account,
740
+ label: card.label,
741
+ groupBy,
742
+ opening: { ...card.opening, net: openNet },
743
+ groups,
744
+ closing: {
745
+ ...card.closing,
746
+ debit: card.turnover.debit,
747
+ credit: card.turnover.credit,
748
+ turnover: round(card.turnover.debit - card.turnover.credit, 2),
749
+ net: round(card.closing.debit - card.closing.credit, 2),
750
+ count: card.total,
751
+ },
752
+ total: card.total,
753
+ items: q.group ? items.filter(i => i.groupKey === String(q.group)) : items,
754
+ range: card.range,
755
+ names: card.names,
756
+ };
757
+ }
758
+
759
+ /**
760
+ * Сборная карточка проводки (Экран 4 ODANT):
761
+ * микро-отчёт по ключу `entry`, собираемый из двух явных записей классов счетов
762
+ * в `account_entries` (запись счёта Дебета + запись счёта Кредита) и шапки операции.
763
+ */
764
+ export function readEntry(db, entryId) {
765
+ const id = Number(entryId);
766
+ if (!Number.isFinite(id))
767
+ throw new DataError(`проводка ${entryId ?? ''} не найдена`);
768
+ const accRows = db.prepare('SELECT * FROM account_entries WHERE entry_id = ? ORDER BY side').all(id);
769
+ if (!accRows.length)
770
+ throw new DataError(`проводка ${entryId} не найдена`);
771
+ const dRow = accRows.find(r => r.side === 'D') || accRows[0];
772
+ const cRow = accRows.find(r => r.side === 'C') || accRows.at(-1);
773
+ const dAnalytics = JSON.parse(dRow.analytics || '{}');
774
+ const cAnalytics = JSON.parse(cRow.analytics || '{}');
775
+ const op = readObject(db, dRow.op_id);
776
+ const opSchema = classSchema(db, dRow.op_cid);
777
+ const names = namesOf(db,
778
+ [dRow.op_id, ...Object.values(dAnalytics), ...Object.values(cAnalytics)],
779
+ [dRow.account_cid, cRow.account_cid, dRow.op_cid]);
780
+
781
+ const buildSide = (row, anMap) => {
782
+ const s = classSchema(db, row.account_cid);
783
+ const slots = analyticSlots(s || { fields: [] });
784
+ const list = slots.map(slot => {
785
+ const valId = anMap[slot.id] || null;
786
+ return {
787
+ id: slot.id,
788
+ label: slot.label || slot.id,
789
+ target: slot.target,
790
+ value: valId,
791
+ title: valId ? (names.objects[valId] || valId) : '',
792
+ };
793
+ });
794
+ return {
795
+ account: row.account_cid,
796
+ path: s?.path || row.account_cid,
797
+ label: s?.label || row.account_cid,
798
+ amount: rub(row.side === 'D' ? row.debit : row.credit),
799
+ qty: q6(row.side === 'D' ? row.qty_in : row.qty_out),
800
+ qty2: q6(row.qty2),
801
+ doc_qty: row.doc_qty,
802
+ doc_unit: row.doc_unit,
803
+ analytics: list,
804
+ };
805
+ };
806
+
807
+ return {
808
+ entry: id,
809
+ time: dRow.time,
810
+ rule: dRow.rule,
811
+ row: dRow.row,
812
+ amount: rub(dRow.debit || cRow.credit),
813
+ qty: q6(dRow.qty_in || cRow.qty_out),
814
+ qty2: q6(dRow.qty2),
815
+ doc_qty: dRow.doc_qty,
816
+ doc_unit: dRow.doc_unit,
817
+ storno: !!dRow.storno,
818
+ author: dRow.author,
819
+ place: dRow.place,
820
+ unit: dRow.unit,
821
+ operation: {
822
+ id: dRow.op_id,
823
+ cid: dRow.op_cid,
824
+ path: opSchema?.path || dRow.op_cid,
825
+ label: opSchema?.label || dRow.op_cid,
826
+ title: op?.title || names.objects[dRow.op_id] || opSchema?.label || dRow.op_id,
827
+ time: op?.time ?? dRow.time,
828
+ status: op?.status ?? 'registered',
829
+ },
830
+ debit: buildSide(dRow, dAnalytics),
831
+ credit: buildSide(cRow, cAnalytics),
832
+ names,
833
+ };
834
+ }
835
+
836
+ /* ---------- аналитический объект ---------- */
837
+
838
+ function mustObject(db, id) {
839
+ const obj = isEmpty(id) ? null : readObject(db, id);
840
+ if (!obj)
841
+ throw new DataError(`объект ${id ?? ''} не найден`);
842
+ return obj;
843
+ }
844
+
845
+ /** Сальдо объекта по счетам и слотам аналитики: [{ account, slot, a, q, q2 }]. */
846
+ function objectBalances(db, sql, args, cond, condArgs) {
847
+ return db.prepare(`SELECT acc, slot, coalesce(sum(amt), 0) a, coalesce(sum(q), 0) q, coalesce(sum(q2), 0) q2
848
+ FROM (${sql}) WHERE ${cond} GROUP BY acc, slot ORDER BY acc, slot`).all(...args, ...condArgs);
849
+ }
850
+
851
+ const presentBalances = list => list
852
+ .filter(b => b.a || Math.abs(b.q) > 1e-9 || Math.abs(b.q2) > 1e-9)
853
+ .map(b => ({ account: b.acc, slot: b.slot, ...balance(b.a, b.q, b.q2) }));
854
+
855
+ /**
856
+ * Состояние объекта на момент: версия объекта и его сальдо по всем счетам,
857
+ * где он аналитика (по счёту и слоту).
858
+ * @param {object} q { id, at?, account?, ...общие фильтры }
859
+ */
860
+ export function objectState(db, q = {}) {
861
+ const cur = mustObject(db, q.id);
862
+ const conds = entryConds(db, q);
863
+ conds.push(['e.time < ?', [atBound(q.at)]]);
864
+ const { sql, args } = sideRows({ object: cur.id, accounts: isEmpty(q.account) ? null : accountCids(db, q.account), conds, visible: q.visible });
865
+ const balances = presentBalances(objectBalances(db, sql, args, '1', []));
866
+ return {
867
+ object: isEmpty(q.at) ? cur : readObject(db, cur.id, { at: atBound(q.at) - 1 }),
868
+ balances,
869
+ names: namesOf(db, [], balances.map(b => b.account)),
870
+ };
871
+ }
872
+
873
+ /**
874
+ * Траектория объекта за период: сальдо на начало, движения, где он аналитика
875
+ * (сальдо по счёту и слоту после каждого), сальдо на конец и версии объекта за период.
876
+ * @param {object} q { id, from?, to?, account?, limit=500, offset=0, ...общие фильтры }
877
+ */
878
+ export function objectTrajectory(db, q = {}) {
879
+ const cur = mustObject(db, q.id);
880
+ const [from, to] = periodRange(q.from, q.to);
881
+ const conds = entryConds(db, q);
882
+ conds.push(['e.time < ?', [hi(to)]]);
883
+ const { sql, args } = sideRows({ object: cur.id, accounts: isEmpty(q.account) ? null : accountCids(db, q.account), conds, visible: q.visible });
884
+ const F = lo(from);
885
+ const opening = objectBalances(db, sql, args, 'time < ?', [F]);
886
+ const start = new Map(opening.map(b => [`${b.acc}\n${b.slot}`, b]));
887
+ const total = db.prepare(`SELECT count(*) n FROM (${sql}) WHERE time >= ?`).get(...args, F).n;
888
+ const { limit, offset } = page(q, 500, 5000);
889
+ const rows = db.prepare(`SELECT *, sum(amt) OVER w ba, sum(q) OVER w bq, sum(q2) OVER w bq2 FROM (${sql}) WHERE time >= ?
890
+ WINDOW w AS (PARTITION BY acc, slot ORDER BY time, id, side ROWS UNBOUNDED PRECEDING) ORDER BY time, id, side LIMIT ? OFFSET ?`)
891
+ .all(...args, F, limit, offset);
892
+ const an = analyticsMap(db, rows.map(r => r.id));
893
+ const items = rows.map(r => sideItem(r, an, start.get(`${r.acc}\n${r.slot}`) ?? { a: 0, q: 0, q2: 0 }));
894
+ const closing = presentBalances(objectBalances(db, sql, args, '1', []));
895
+ const versions = db.prepare('SELECT version, valid_from, author, status, body FROM object_versions WHERE id = ? AND valid_from >= ? AND valid_from < ? ORDER BY version')
896
+ .all(cur.id, F, hi(to)).map(v => ({ ...v, body: JSON.parse(v.body) }));
897
+ const openList = presentBalances(opening);
898
+ return {
899
+ object: cur,
900
+ opening: openList,
901
+ items,
902
+ closing,
903
+ versions,
904
+ total,
905
+ names: itemNames(db, items, [cur.id], [...openList.map(b => b.account), ...closing.map(b => b.account)]),
906
+ };
907
+ }
908
+
909
+ /* ---------- виртуальный справочник ---------- */
910
+
911
+ /** Источник виртуального справочника: реестр, счёт и слот аналитики (SOURCE в class.js). */
912
+ function virtualSource(db, ref) {
913
+ const s = classSchema(db, ref);
914
+ if (!s || s.type !== '$virtual')
915
+ throw new DataError(`виртуальный справочник ${ref} не найден`);
916
+ const src = s.source || {};
917
+ if (isEmpty(src.registry) || isEmpty(src.account))
918
+ throw new DataError(`${s.label}: в SOURCE не заданы реестр (registry) и счёт (account)`);
919
+ const reg = getClass(db, src.registry);
920
+ if (!reg)
921
+ throw new DataError(`${s.label}: реестр ${src.registry} не найден`);
922
+ const acc = mustClass(db, src.account, 'account', `${s.label}: счёт`);
923
+ const regCids = descendants(db, reg.cid);
924
+ let slot = isEmpty(src.slot) ? null : String(src.slot);
925
+ if (!slot) {
926
+ const fits = analyticSlots(classSchema(db, acc.cid)).filter(f => linkCids(db, f.target)?.some(c => regCids.includes(c)));
927
+ if (fits.length !== 1)
928
+ throw new DataError(`${s.label}: у счёта ${acc.label} ${fits.length ? 'несколько аналитик' : 'нет аналитики'} по реестру ${reg.label} — укажите SOURCE.slot`);
929
+ slot = fits[0].id;
930
+ }
931
+ const mode = String(src.mode || 'balance').toLowerCase();
932
+ return { schema: s, registry: reg, regCids, accounts: descendants(db, acc.cid), slot, mode };
933
+ }
934
+
935
+ /**
936
+ * Состав виртуального справочника на момент:
937
+ * - при `mode: 'balance'` (по умолчанию) — объекты реестра-источника с ненулевым сальдо счёта на дату;
938
+ * - при `mode: 'turnover'` — все объекты реестра-источника, у которых на дату были движения (обороты) по счёту.
939
+ * @param {object} q { cid, at?, mode?, ...общие фильтры }
940
+ * @returns {{ cid, label, slot, mode, registry: { cid, path, label },
941
+ * items: Array<{ id, cid, title, body, balance, turnover?: object, ext: { id, version, body } | null }> }}
942
+ */
943
+ export function virtualMembers(db, q = {}) {
944
+ const { schema, registry, regCids, accounts, slot, mode: srcMode } = virtualSource(db, q.cid);
945
+ const mode = String(q.mode || srcMode || 'balance').toLowerCase();
946
+ const bound = atBound(q.at);
947
+ const conds = entryConds(db, q);
948
+ conds.push(['e.time < ?', [bound]]);
949
+ const { sql, args } = sideRows({ accounts, by: [slot], conds, visible: q.visible });
950
+ const having = mode === 'turnover'
951
+ ? 'HAVING da <> 0 OR ca <> 0 OR abs(dq) > 1e-9 OR abs(cq) > 1e-9 OR sum(amt) <> 0 OR abs(sum(q)) > 1e-9 OR abs(sum(q2)) > 1e-9'
952
+ : 'HAVING sum(amt) <> 0 OR abs(sum(q)) > 1e-9 OR abs(sum(q2)) > 1e-9';
953
+ const rows = db.prepare(`SELECT k0 id, sum(amt) a, sum(q) q, sum(q2) q2,
954
+ coalesce(sum(CASE WHEN side = 'D' THEN amt ELSE 0 END), 0) da,
955
+ coalesce(sum(CASE WHEN side = 'C' THEN -amt ELSE 0 END), 0) ca,
956
+ coalesce(sum(CASE WHEN side = 'D' THEN q ELSE 0 END), 0) dq,
957
+ coalesce(sum(CASE WHEN side = 'C' THEN -q ELSE 0 END), 0) cq
958
+ FROM (${sql}) WHERE k0 IS NOT NULL GROUP BY k0 ${having}`).all(...args);
959
+ const at = bound === HIGH ? undefined : bound - 1;
960
+ const ext = db.prepare(`SELECT id FROM objects WHERE cid = ? AND ext_of = ? AND status <> 'deleted'`);
961
+ const vAncs = ancestors(db, schema.cid).filter(a => a.type === '$virtual');
962
+ const items = [];
963
+ for (const r of rows) {
964
+ const obj = readObject(db, r.id, { at }) ?? readObject(db, r.id);
965
+ if (!obj || !regCids.includes(obj.cid))
966
+ continue;
967
+ let mergedExtBody = null;
968
+ let ownExtObj = null;
969
+ for (const anc of vAncs) {
970
+ const e = ext.get(anc.cid, obj.id);
971
+ const eo = e ? readObject(db, e.id, { at }) ?? readObject(db, e.id) : null;
972
+ if (eo) {
973
+ mergedExtBody = { ...(mergedExtBody || {}), ...(eo.body || {}) };
974
+ if (anc.cid === schema.cid)
975
+ ownExtObj = eo;
976
+ }
977
+ }
978
+ items.push({
979
+ id: obj.id,
980
+ cid: obj.cid,
981
+ title: obj.title,
982
+ body: obj.body,
983
+ balance: balance(r.a, r.q, r.q2),
984
+ turnover: { debit: rub(r.da), credit: rub(r.ca), qty_debit: q6(r.dq), qty_credit: q6(r.cq) },
985
+ ext: ownExtObj
986
+ ? { id: ownExtObj.id, version: ownExtObj.version, body: mergedExtBody || ownExtObj.body }
987
+ : (mergedExtBody ? { id: null, version: null, body: mergedExtBody } : null),
988
+ });
989
+ }
990
+ items.sort((a, b) => a.title.localeCompare(b.title, 'ru'));
991
+ return { cid: schema.cid, label: schema.label, slot, mode, registry: { cid: registry.cid, path: registry.path, label: registry.label }, items };
992
+ }
993
+
994
+ /** Быстрый подсчёт количества участников виртуального справочника без чтения JSON-тел объектов. */
995
+ export function countVirtualMembers(db, cid) {
996
+ const { regCids, accounts, slot, mode: srcMode } = virtualSource(db, cid);
997
+ if (!regCids.length || !accounts.length)
998
+ return 0;
999
+ const mode = String(srcMode || 'balance').toLowerCase();
1000
+ const { sql, args } = sideRows({ accounts, by: [slot], conds: [] });
1001
+ const having = mode === 'turnover'
1002
+ ? 'HAVING da <> 0 OR ca <> 0 OR abs(dq) > 1e-9 OR abs(cq) > 1e-9 OR sum(amt) <> 0 OR abs(sum(q)) > 1e-9 OR abs(sum(q2)) > 1e-9'
1003
+ : 'HAVING sum(amt) <> 0 OR abs(sum(q)) > 1e-9 OR abs(sum(q2)) > 1e-9';
1004
+ const r = db.prepare(`SELECT count(*) n FROM (
1005
+ SELECT k0 id,
1006
+ coalesce(sum(CASE WHEN side = 'D' THEN amt ELSE 0 END), 0) da,
1007
+ coalesce(sum(CASE WHEN side = 'C' THEN -amt ELSE 0 END), 0) ca,
1008
+ coalesce(sum(CASE WHEN side = 'D' THEN q ELSE 0 END), 0) dq,
1009
+ coalesce(sum(CASE WHEN side = 'C' THEN -q ELSE 0 END), 0) cq
1010
+ FROM (${sql}) WHERE k0 IS NOT NULL GROUP BY k0 ${having}
1011
+ ) g JOIN objects o ON o.id = g.id WHERE o.status <> 'deleted' AND o.cid IN (${regCids.map(() => '?').join(',')})`).get(...args, ...regCids);
1012
+ return Number(r?.n) || 0;
1013
+ }
1014
+
1015
+ /** Объект — участник виртуального справочника на момент (`visible` — только по видимым проводкам). */
1016
+ export function isVirtualMember(db, cid, id, at, visible = null, modeOverride = null) {
1017
+ const { regCids, accounts, slot, mode: srcMode } = virtualSource(db, cid);
1018
+ const mode = String(modeOverride || srcMode || 'balance').toLowerCase();
1019
+ const conds = [['e.time < ?', [atBound(at)]]];
1020
+ const { sql, args } = sideRows({ accounts, analytics: { [slot]: id }, conds, visible });
1021
+ const r = db.prepare(`SELECT coalesce(sum(amt), 0) a, coalesce(sum(q), 0) q, coalesce(sum(q2), 0) q2,
1022
+ coalesce(sum(CASE WHEN side = 'D' THEN amt ELSE 0 END), 0) da,
1023
+ coalesce(sum(CASE WHEN side = 'C' THEN -amt ELSE 0 END), 0) ca,
1024
+ coalesce(sum(CASE WHEN side = 'D' THEN q ELSE 0 END), 0) dq,
1025
+ coalesce(sum(CASE WHEN side = 'C' THEN -q ELSE 0 END), 0) cq
1026
+ FROM (${sql})`).get(...args);
1027
+ const obj = db.prepare('SELECT cid FROM objects WHERE id = ?').get(String(id));
1028
+ if (!obj || !regCids.includes(obj.cid))
1029
+ return false;
1030
+ if (mode === 'turnover')
1031
+ return r.da !== 0 || r.ca !== 0 || Math.abs(r.dq) > 1e-9 || Math.abs(r.cq) > 1e-9 || r.a !== 0 || Math.abs(r.q) > 1e-9 || Math.abs(r.q2) > 1e-9;
1032
+ return r.a !== 0 || Math.abs(r.q) > 1e-9 || Math.abs(r.q2) > 1e-9;
1033
+ }