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,950 @@
1
+ /**
2
+ * Объекты справочников и операций: создание, правка с версиями во времени,
3
+ * удаление с защитой ссылок, чтение «как было на дату», выборки и поиск.
4
+ *
5
+ * Объект создаётся только в классе-листе своего семейства (справочник или
6
+ * операция). Тело проверяется по схеме класса (schema.js): типы приводятся,
7
+ * обязательные поля проверяются, ссылки должны вести на существующие
8
+ * неудалённые объекты целевого класса или его потомков, лишние поля — ошибка.
9
+ *
10
+ * Каждая запись — новая версия в object_versions; `valid_from` версии задаёт,
11
+ * с какого момента она действует (первая версия справочника действует всегда).
12
+ * Зарегистрированная операция здесь не меняется — это делает проведение
13
+ * (posting.js: исправление — сторно и новые проводки). Операция класса с
14
+ * AUTO_REGISTER регистрируется в транзакции создания.
15
+ */
16
+ import crypto from 'node:crypto';
17
+ import { tx } from './db.js';
18
+ import { getClass, descendants } from './classes.js';
19
+ import { classSchema, objectTitle, FAMILY } from './schema.js';
20
+ import { toNumber, round } from './expr.js';
21
+ import { DataError } from './errors.js';
22
+ import { parseTime, localDay, periodRange } from './time.js';
23
+ import { registerOperation } from './posting.js';
24
+ import { objectCond } from './scope.js';
25
+
26
+ /** Допустимые статусы по семейству. */
27
+ export const STATUSES = Object.freeze({
28
+ catalog: ['active', 'deleted'],
29
+ operation: ['draft', 'registered', 'cancelled', 'deleted'],
30
+ });
31
+
32
+ /** Системные колонки, доступные в фильтрах и сортировке наравне с полями. */
33
+ const COLUMNS = new Set(['id', 'cid', 'status', 'time', 'author', 'place', 'unit', 'ext_of', 'version', 'created', 'updated']);
34
+
35
+ const MAX_LIMIT = 500;
36
+
37
+ /** Новый id объекта: время создания (base36) + случайный хвост; сортируется по времени. */
38
+ export function newId(now = Date.now()) {
39
+ return now.toString(36).padStart(9, '0') + crypto.randomBytes(5).toString('hex');
40
+ }
41
+
42
+ const isEmpty = v => v === undefined || v === null || v === '';
43
+
44
+ /* ---------- проверка тела по схеме ---------- */
45
+
46
+ /** Классы, объекты которых допустимы в ссылке на target. Виртуальный — объекты его реестра-источника. */
47
+ export function linkCids(db, target) {
48
+ const t = classSchema(db, target);
49
+ if (!t)
50
+ return null;
51
+ if (t.type === '$virtual' && t.source?.registry) {
52
+ const reg = getClass(db, t.source.registry);
53
+ return reg ? descendants(db, reg.cid) : null;
54
+ }
55
+ return descendants(db, t.cid);
56
+ }
57
+
58
+ function coerce(db, field, value, errors, path) {
59
+ const fail = msg => {
60
+ errors.push({ field: path, message: msg });
61
+ return undefined;
62
+ };
63
+ switch (field.type) {
64
+ case 'string':
65
+ case 'text':
66
+ case 'file':
67
+ return String(value);
68
+ case 'enum': {
69
+ const values = (field.values || field.options || []).map(v => (typeof v === 'object' ? String(v.id) : String(v)));
70
+ const s = String(typeof value === 'object' ? value?.id : value);
71
+ return values.length && !values.includes(s) ? fail(`«${s}» — нет в списке (${values.join(', ')})`) : s;
72
+ }
73
+ case 'number':
74
+ case 'money':
75
+ case 'integer': {
76
+ let n;
77
+ try {
78
+ n = toNumber(typeof value === 'string' ? value.replace(/\s/g, '').replace(',', '.') : value);
79
+ }
80
+ catch {
81
+ return fail(`«${value}» — не число`);
82
+ }
83
+ if (field.type === 'integer' && !Number.isInteger(n))
84
+ return fail(`«${value}» — не целое`);
85
+ return field.type === 'money' ? round(n, 2) : n;
86
+ }
87
+ case 'boolean': {
88
+ if (typeof value === 'boolean')
89
+ return value;
90
+ const s = String(value).trim().toLowerCase();
91
+ if (['true', '1', 'да', 'yes'].includes(s))
92
+ return true;
93
+ if (['false', '0', 'нет', 'no'].includes(s))
94
+ return false;
95
+ return fail(`«${value}» — не да/нет`);
96
+ }
97
+ case 'date':
98
+ case 'datetime': {
99
+ let t;
100
+ try {
101
+ t = parseTime(value);
102
+ }
103
+ catch {
104
+ return fail(`«${value}» — не дата`);
105
+ }
106
+ return field.type === 'date' ? localDay(t) : t;
107
+ }
108
+ case 'link': {
109
+ const id = String(typeof value === 'object' ? value?.id ?? '' : value);
110
+ const cids = linkCids(db, field.target);
111
+ if (!cids)
112
+ return fail(`класс ссылки ${field.target} не найден`);
113
+ const row = db.prepare('SELECT cid, status FROM objects WHERE id = ?').get(id);
114
+ if (!row || !cids.includes(row.cid))
115
+ return fail(`объект ${id} не найден в ${getClass(db, field.target)?.label ?? field.target}`);
116
+ if (row.status === 'deleted')
117
+ return fail(`объект ${id} удалён`);
118
+ return id;
119
+ }
120
+ case 'struct': {
121
+ if (!value || typeof value !== 'object' || Array.isArray(value))
122
+ return fail('ожидается вложенный объект (структура)');
123
+ return checkBody(db, field.fields, value, errors, `${path}.`);
124
+ }
125
+ case 'table': {
126
+ if (!Array.isArray(value))
127
+ return fail('ожидается список строк');
128
+ return value.map((row, i) => checkBody(db, field.fields, row, errors, `${path}[${i + 1}].`));
129
+ }
130
+ }
131
+ return fail(`неизвестный тип ${field.type}`);
132
+ }
133
+
134
+ /**
135
+ * Проверить и привести тело по списку полей.
136
+ * @returns {object} приведённое тело (пустые значения не хранятся)
137
+ */
138
+ function checkBody(db, fields, body, errors, prefix = '') {
139
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
140
+ errors.push({ field: prefix.replace(/\.$/, '') || undefined, message: 'ожидается объект' });
141
+ return {};
142
+ }
143
+ const byId = new Map(fields.map(f => [f.id, f]));
144
+ for (const key of Object.keys(body)) {
145
+ if (!byId.has(key))
146
+ errors.push({ field: prefix + key, message: 'нет такого поля в схеме класса' });
147
+ }
148
+ const out = {};
149
+ for (const f of fields) {
150
+ const v = body[f.id];
151
+ const emptyStruct = f.type === 'struct' && v && typeof v === 'object' && !Array.isArray(v) && !Object.keys(v).length;
152
+ if (isEmpty(v) || emptyStruct || (Array.isArray(v) && !v.length && f.type === 'table')) {
153
+ if (f.required)
154
+ errors.push({ field: prefix + f.id, message: 'обязательное поле' });
155
+ continue;
156
+ }
157
+ const c = coerce(db, f, v, errors, prefix + f.id);
158
+ if (c !== undefined && !(f.type === 'struct' && typeof c === 'object' && !Object.keys(c).length))
159
+ out[f.id] = c;
160
+ }
161
+ return out;
162
+ }
163
+
164
+ /** Поля тела объекта класса (для записи-расширения виртуального справочника — его поля и OVERLAY). */
165
+ function bodyFields(schema) {
166
+ return schema.type === '$virtual' ? [...schema.fields, ...schema.overlay] : schema.fields;
167
+ }
168
+
169
+ /** Проверить тело объекта класса; ошибки — DataError со списком. */
170
+ export function validateBody(db, schema, body) {
171
+ const errors = [];
172
+ const out = checkBody(db, bodyFields(schema), body ?? {}, errors);
173
+ if (errors.length)
174
+ throw new DataError(`${schema.label}: данные не прошли проверку`, errors);
175
+ return out;
176
+ }
177
+
178
+ /* ---------- производные: ссылки и поиск ---------- */
179
+
180
+ function collectRefs(db, fields, body, prefix = '', row = -1, out = []) {
181
+ for (const f of fields) {
182
+ const v = body[f.id];
183
+ if (isEmpty(v))
184
+ continue;
185
+ if (f.type === 'link')
186
+ out.push({ field: prefix + f.id, row, ref_id: v, ref_cid: db.prepare('SELECT cid FROM objects WHERE id = ?').get(v)?.cid ?? null });
187
+ if (f.type === 'struct' && v && typeof v === 'object')
188
+ collectRefs(db, f.fields, v, prefix + f.id + '.', row, out);
189
+ if (f.type === 'table' && Array.isArray(v))
190
+ v.forEach((r, i) => collectRefs(db, f.fields, r, prefix + f.id + '.', row >= 0 ? row : i, out));
191
+ }
192
+ return out;
193
+ }
194
+
195
+ function searchText(schema, body, db = null) {
196
+ const parts = [objectTitle(schema, body)];
197
+ const walk = (fields, b) => {
198
+ for (const f of fields || []) {
199
+ const v = b?.[f.id];
200
+ if (isEmpty(v))
201
+ continue;
202
+ if (['string', 'text', 'enum', 'number', 'money', 'integer', 'date', 'datetime', 'file'].includes(f.type)) {
203
+ parts.push(String(v));
204
+ }
205
+ else if (f.type === 'link' && db) {
206
+ try {
207
+ const refRow = db.prepare('SELECT cid, body FROM objects WHERE id = ?').get(String(v));
208
+ if (refRow) {
209
+ const refSchema = classSchema(db, refRow.cid);
210
+ const refBody = JSON.parse(refRow.body);
211
+ const refTitle = objectTitle(refSchema, refBody);
212
+ if (refTitle)
213
+ parts.push(String(refTitle));
214
+ }
215
+ }
216
+ catch { /* ignore */ }
217
+ }
218
+ if (f.type === 'struct' && v && typeof v === 'object')
219
+ walk(f.fields, v);
220
+ if (f.type === 'table' && Array.isArray(v))
221
+ v.forEach(r => walk(f.fields, r));
222
+ }
223
+ };
224
+ walk(bodyFields(schema), body);
225
+ return parts.filter(Boolean).join('\n');
226
+ }
227
+
228
+ function writeDerived(db, schema, id, body) {
229
+ db.prepare('DELETE FROM refs WHERE obj_id = ?').run(id);
230
+ const ins = db.prepare('INSERT INTO refs (obj_id, field, row, ref_id, ref_cid) VALUES (?, ?, ?, ?, ?)');
231
+ for (const r of collectRefs(db, bodyFields(schema), body))
232
+ ins.run(id, r.field, r.row, r.ref_id, r.ref_cid);
233
+ const rowid = db.prepare('SELECT rowid FROM objects WHERE id = ?').get(id).rowid;
234
+ db.prepare('DELETE FROM objects_fts WHERE rowid = ?').run(rowid);
235
+ db.prepare('INSERT INTO objects_fts (rowid, text) VALUES (?, ?)').run(rowid, searchText(schema, body, db));
236
+ }
237
+
238
+ /* ---------- чтение ---------- */
239
+
240
+ function toObject(row, schema) {
241
+ if (!row)
242
+ return null;
243
+ const body = JSON.parse(row.body);
244
+ return { ...row, body, title: objectTitle(schema, body) };
245
+ }
246
+
247
+ const SELECT = 'SELECT id, cid, status, time, author, place, unit, ext_of, version, created, updated, body FROM objects';
248
+
249
+ /**
250
+ * Объект по id. С `at` — состояние версии, действовавшей в этот момент
251
+ * (null, если объекта тогда ещё не было).
252
+ * @param {object} [opts] { at?: момент (мс, дата, ISO) }
253
+ */
254
+ export function readObject(db, id, { at } = {}) {
255
+ const row = db.prepare(`${SELECT} WHERE id = ?`).get(String(id));
256
+ if (!row)
257
+ return null;
258
+ const schema = classSchema(db, row.cid);
259
+ if (at === undefined || at === null || at === '')
260
+ return toObject(row, schema);
261
+ const v = db.prepare(`SELECT version, status, body, valid_from FROM object_versions
262
+ WHERE id = ? AND valid_from <= ? ORDER BY valid_from DESC, version DESC LIMIT 1`).get(row.id, parseTime(at));
263
+ return v ? toObject({ ...row, version: v.version, status: v.status, body: v.body }, schema) : null;
264
+ }
265
+
266
+ /** Версии объекта, от первой к последней. */
267
+ export function objectVersions(db, id) {
268
+ return db.prepare('SELECT version, valid_from, author, status, body FROM object_versions WHERE id = ? ORDER BY version')
269
+ .all(String(id)).map(v => ({ ...v, body: JSON.parse(v.body) }));
270
+ }
271
+
272
+ /** Кто ссылается на объект: объекты (refs) и проводки (аналитики). */
273
+ export function referencedBy(db, id) {
274
+ const objects = db.prepare(`SELECT r.obj_id, r.field, r.row, o.cid, o.status FROM refs r JOIN objects o ON o.id = r.obj_id
275
+ WHERE r.ref_id = ? AND o.status <> 'deleted'`).all(String(id));
276
+ const entries = db.prepare('SELECT count(*) n FROM entry_analytics WHERE ref_id = ?').get(String(id)).n;
277
+ return { objects, entries };
278
+ }
279
+
280
+ /* ---------- запись ---------- */
281
+
282
+ function mustSchema(db, cid) {
283
+ const schema = classSchema(db, cid);
284
+ if (!schema)
285
+ throw new DataError(`класс ${cid} не найден`);
286
+ if (schema.family !== 'catalog' && schema.family !== 'operation')
287
+ throw new DataError(`${schema.label}: в классе типа ${schema.type} объекты не создаются`);
288
+ if (!schema.leaf)
289
+ throw new DataError(`${schema.label}: объекты создаются только в конечном классе (листе)`);
290
+ return schema;
291
+ }
292
+
293
+ function checkUnit(db, unit) {
294
+ if (isEmpty(unit))
295
+ return null;
296
+ const c = getClass(db, unit);
297
+ if (!c || FAMILY[c.type] !== 'structure')
298
+ throw new DataError(`подразделение ${unit} не найдено`);
299
+ return c.cid;
300
+ }
301
+
302
+ /**
303
+ * Вычислить следующий номер операции по настройке нумератора класса (schema.numerator).
304
+ * По умолчанию счётчик обнуляется в начале каждого календарного года (reset: 'year').
305
+ */
306
+ export function nextOperationNumber(db, schemaOrCid, time) {
307
+ const schema = (schemaOrCid && typeof schemaOrCid === 'object') ? schemaOrCid : classSchema(db, schemaOrCid);
308
+ if (!schema || schema.family !== 'operation' || !schema.numerator?.enabled || !schema.numerator?.field)
309
+ return '';
310
+ const numCfg = schema.numerator;
311
+ const fieldId = numCfg.field;
312
+ const ms = isEmpty(time) ? Date.now() : parseTime(time, 'дата объекта');
313
+ const d = new Date(ms);
314
+ const y = d.getFullYear();
315
+ const m = d.getMonth();
316
+ const day = d.getDate();
317
+
318
+ let fromMs = null;
319
+ let toMs = null;
320
+ const reset = numCfg.reset || 'year';
321
+ if (reset === 'year') {
322
+ fromMs = new Date(y, 0, 1, 0, 0, 0, 0).getTime();
323
+ toMs = new Date(y, 11, 31, 23, 59, 59, 999).getTime();
324
+ }
325
+ else if (reset === 'quarter') {
326
+ const qStart = Math.floor(m / 3) * 3;
327
+ fromMs = new Date(y, qStart, 1, 0, 0, 0, 0).getTime();
328
+ toMs = new Date(y, qStart + 3, 0, 23, 59, 59, 999).getTime();
329
+ }
330
+ else if (reset === 'month') {
331
+ fromMs = new Date(y, m, 1, 0, 0, 0, 0).getTime();
332
+ toMs = new Date(y, m + 1, 0, 23, 59, 59, 999).getTime();
333
+ }
334
+ else if (reset === 'day') {
335
+ fromMs = new Date(y, m, day, 0, 0, 0, 0).getTime();
336
+ toMs = new Date(y, m, day, 23, 59, 59, 999).getTime();
337
+ }
338
+
339
+ const jsonPath = `$."${String(fieldId).replace(/"/g, '""')}"`;
340
+ const rows = fromMs != null
341
+ ? db.prepare(`SELECT json_extract(body, ?) AS val FROM objects WHERE cid = ? AND status <> 'deleted' AND ext_of IS NULL AND time >= ? AND time <= ?`).all(jsonPath, schema.cid, fromMs, toMs)
342
+ : db.prepare(`SELECT json_extract(body, ?) AS val FROM objects WHERE cid = ? AND status <> 'deleted' AND ext_of IS NULL`).all(jsonPath, schema.cid);
343
+
344
+ let maxNum = 0;
345
+ for (const r of rows) {
346
+ if (r?.val == null || r.val === '')
347
+ continue;
348
+ const s = String(r.val).trim();
349
+ const match = /(\d+)\s*$/.exec(s);
350
+ if (match) {
351
+ const n = parseInt(match[1], 10);
352
+ if (Number.isFinite(n) && n > maxNum)
353
+ maxNum = n;
354
+ }
355
+ }
356
+ const nextInt = maxNum + 1;
357
+ const pad = Math.max(0, Number(numCfg.pad) || 0);
358
+ const numStr = pad > 0 ? String(nextInt).padStart(pad, '0') : String(nextInt);
359
+ const prefix = String(numCfg.prefix || '')
360
+ .replace(/\{YYYY\}/g, String(y))
361
+ .replace(/\{YY\}/g, String(y).slice(-2))
362
+ .replace(/\{MM\}/g, String(m + 1).padStart(2, '0'));
363
+ return prefix + numStr;
364
+ }
365
+
366
+ /**
367
+ * Создать объект.
368
+ * @param {object} p
369
+ * @param {string} p.cid Класс-лист (cid или путь)
370
+ * @param {object} [p.body] Значения полей
371
+ * @param {*} [p.time] Дата объекта/операции (по умолчанию — сейчас)
372
+ * @param {string} [p.author] uid автора
373
+ * @param {string} [p.place] Рабочее место
374
+ * @param {string} [p.unit] Подразделение (cid структуры)
375
+ * @param {string} [p.ext_of] Для виртуального справочника — id реального объекта
376
+ * @param {*} [p.valid_from] С какого момента действует первая версия (справочник — всегда)
377
+ */
378
+ export function createObject(db, p) {
379
+ const schema = mustSchema(db, p.cid);
380
+ const now = Date.now();
381
+ const status = schema.family === 'operation' ? 'draft' : 'active';
382
+ const time = isEmpty(p.time) ? now : parseTime(p.time, 'дата объекта');
383
+ const unit = checkUnit(db, p.unit);
384
+ let extOf = null;
385
+ if (schema.type === '$virtual') {
386
+ const cids = linkCids(db, schema.cid);
387
+ const real = isEmpty(p.ext_of) ? null : db.prepare('SELECT cid, status FROM objects WHERE id = ?').get(String(p.ext_of));
388
+ if (!real || !cids?.includes(real.cid) || real.status === 'deleted')
389
+ throw new DataError(`${schema.label}: запись-расширение требует существующий объект реестра (ext_of)`);
390
+ if (db.prepare(`SELECT 1 FROM objects WHERE cid = ? AND ext_of = ? AND status <> 'deleted'`).get(schema.cid, String(p.ext_of)))
391
+ throw new DataError(`${schema.label}: расширение для ${p.ext_of} уже есть`);
392
+ extOf = String(p.ext_of);
393
+ }
394
+ else if (!isEmpty(p.ext_of))
395
+ throw new DataError(`${schema.label}: ext_of допустим только в виртуальном справочнике`);
396
+ let rawBody = p.body ?? {};
397
+ if (schema.family === 'operation' && schema.numerator?.enabled && schema.numerator?.field && rawBody && typeof rawBody === 'object' && !Array.isArray(rawBody)) {
398
+ const nf = schema.numerator.field;
399
+ if (isEmpty(rawBody[nf])) {
400
+ const autoNum = nextOperationNumber(db, schema, time);
401
+ if (autoNum)
402
+ rawBody = { ...rawBody, [nf]: autoNum };
403
+ }
404
+ }
405
+ const body = validateBody(db, schema, rawBody);
406
+ const validFrom = isEmpty(p.valid_from) ? (schema.family === 'catalog' ? 0 : now) : parseTime(p.valid_from, 'начало действия');
407
+ const id = newId(now);
408
+ tx(db, () => {
409
+ db.prepare(`INSERT INTO objects (id, cid, status, time, author, place, unit, ext_of, version, created, updated, body)
410
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)`)
411
+ .run(id, schema.cid, status, time, p.author ?? null, p.place ?? null, unit, extOf, now, now, JSON.stringify(body));
412
+ db.prepare('INSERT INTO object_versions (id, version, valid_from, author, status, body) VALUES (?, 1, ?, ?, ?, ?)')
413
+ .run(id, validFrom, p.author ?? null, status, JSON.stringify(body));
414
+ writeDerived(db, schema, id, body);
415
+ if (!isEmpty(p.basis_id)) {
416
+ const srcId = String(p.basis_id).trim();
417
+ if (srcId && srcId !== id) {
418
+ db.prepare(`INSERT INTO doc_links (source_id, target_id, kind, note, author, created)
419
+ VALUES (?, ?, 'basis', ?, ?, ?)
420
+ ON CONFLICT(source_id, target_id) DO UPDATE SET note = excluded.note`)
421
+ .run(srcId, id, String(p.basis_note || ''), p.author ?? null, now);
422
+ }
423
+ }
424
+ // кто бы ни создавал (человек, ИИ, триггер): ошибка проведения отменяет и создание
425
+ if (schema.family === 'operation' && schema.autoRegister)
426
+ registerOperation(db, { id, author: p.author });
427
+ });
428
+ return readObject(db, id);
429
+ }
430
+
431
+ /**
432
+ * Записать новую версию объекта (общий путь правки, удаления и смены статуса).
433
+ * Внутренний вход для проведения: опция `system` разрешает менять зарегистрированную операцию.
434
+ * @returns {object} объект после записи
435
+ */
436
+ export function writeVersion(db, id, { body, status, time, author, valid_from, version, system = false, force = false } = {}) {
437
+ const cur = readObject(db, id);
438
+ if (!cur)
439
+ throw new DataError(`объект ${id} не найден`);
440
+ const schema = classSchema(db, cur.cid);
441
+ if (version !== undefined && version !== null && Number(version) !== cur.version)
442
+ throw new DataError(`объект ${cur.title || id} уже изменён (версия ${cur.version}, а не ${version}) — обновите и повторите`);
443
+ if (!system && schema.family === 'operation' && cur.status !== 'draft' && cur.status !== 'cancelled')
444
+ throw new DataError(`операция «${cur.title || id}» зарегистрирована — изменить её можно только исправлением`);
445
+ const nextStatus = status ?? (schema.family === 'operation' && cur.status === 'cancelled' ? 'draft' : cur.status);
446
+ if (!STATUSES[schema.family].includes(nextStatus))
447
+ throw new DataError(`статус «${nextStatus}» недопустим для ${schema.label}`);
448
+ const nextBody = body === undefined ? cur.body : validateBody(db, schema, body);
449
+ const nextTime = isEmpty(time) ? cur.time : parseTime(time, 'дата объекта');
450
+ if (!force && nextStatus === cur.status && nextTime === cur.time && JSON.stringify(nextBody) === JSON.stringify(cur.body))
451
+ return cur;
452
+ const now = Date.now();
453
+ const last = db.prepare('SELECT max(valid_from) v FROM object_versions WHERE id = ?').get(cur.id).v;
454
+ const validFrom = isEmpty(valid_from) ? now : parseTime(valid_from, 'начало действия');
455
+ if (validFrom < last)
456
+ throw new DataError(`начало действия версии раньше предыдущей (${localDay(last)})`);
457
+ const v = cur.version + 1;
458
+ tx(db, () => {
459
+ db.prepare('UPDATE objects SET status = ?, time = ?, version = ?, updated = ?, body = ? WHERE id = ?')
460
+ .run(nextStatus, nextTime, v, now, JSON.stringify(nextBody), cur.id);
461
+ db.prepare('INSERT INTO object_versions (id, version, valid_from, author, status, body) VALUES (?, ?, ?, ?, ?, ?)')
462
+ .run(cur.id, v, validFrom, author ?? null, nextStatus, JSON.stringify(nextBody));
463
+ if (nextBody !== cur.body)
464
+ writeDerived(db, schema, cur.id, nextBody);
465
+ });
466
+ return readObject(db, cur.id);
467
+ }
468
+
469
+ /**
470
+ * Изменить поля объекта.
471
+ * @param {object} p { id, body — изменяемые поля (null — очистить), replace? — тело целиком,
472
+ * time?, author?, valid_from? — с какого момента действует, version? — ожидаемая версия }
473
+ */
474
+ export function updateObject(db, p) {
475
+ const cur = readObject(db, p.id);
476
+ if (!cur)
477
+ throw new DataError(`объект ${p.id} не найден`);
478
+ if (cur.status === 'deleted')
479
+ throw new DataError(`объект «${cur.title || p.id}» удалён`);
480
+ return writeVersion(db, cur.id, { body: mergeBody(cur.body, p), time: p.time, author: p.author, valid_from: p.valid_from, version: p.version });
481
+ }
482
+
483
+ /** Тело после правки: изменяемые поля поверх текущих (null — очистить) или целиком при replace. */
484
+ export function mergeBody(current, { body, replace } = {}) {
485
+ if (body === undefined)
486
+ return current;
487
+ const out = replace ? { ...body } : { ...current, ...body };
488
+ for (const [k, v] of Object.entries(out)) {
489
+ if (v === null)
490
+ delete out[k];
491
+ }
492
+ return out;
493
+ }
494
+
495
+ /**
496
+ * Удалить объект (отметка, история сохраняется). Справочник — только если на него
497
+ * не ссылаются объекты и проводки; операция — только черновик.
498
+ */
499
+ export function deleteObject(db, { id, author, version } = {}) {
500
+ const cur = readObject(db, id);
501
+ if (!cur)
502
+ throw new DataError(`объект ${id} не найден`);
503
+ if (cur.status === 'deleted')
504
+ return cur;
505
+ const schema = classSchema(db, cur.cid);
506
+ if (schema.family === 'operation' && cur.status !== 'draft' && cur.status !== 'cancelled')
507
+ throw new DataError(`операция «${cur.title || id}» зарегистрирована — удалить можно только черновик или отменённую операцию`);
508
+ const refs = referencedBy(db, cur.id);
509
+ if (refs.objects.length || refs.entries)
510
+ throw new DataError(`«${cur.title || id}» используется: объектов — ${refs.objects.length}, проводок — ${refs.entries}`);
511
+ return writeVersion(db, cur.id, { status: 'deleted', author, version });
512
+ }
513
+
514
+ /** Восстановить удалённый объект. */
515
+ export function restoreObject(db, { id, author } = {}) {
516
+ const cur = readObject(db, id);
517
+ if (!cur)
518
+ throw new DataError(`объект ${id} не найден`);
519
+ if (cur.status !== 'deleted')
520
+ return cur;
521
+ const schema = classSchema(db, cur.cid);
522
+ return writeVersion(db, cur.id, { status: schema.family === 'operation' ? 'draft' : 'active', author, system: true });
523
+ }
524
+
525
+ /* ---------- выборки ---------- */
526
+
527
+ const OPS = { eq: '=', ne: '<>', gt: '>', gte: '>=', lt: '<', lte: '<=' };
528
+
529
+ /** Поля класса и его потомков (запрос к родителю фильтрует и по полям наследников, включая поля вложенных структур). */
530
+ function knownFields(db, schema) {
531
+ const ids = new Set();
532
+ const add = (list, prefix = '') => {
533
+ for (const f of list) {
534
+ ids.add(prefix + f.id);
535
+ if (f.type === 'struct' && Array.isArray(f.fields))
536
+ add(f.fields, prefix + f.id + '.');
537
+ }
538
+ };
539
+ for (const cid of descendants(db, schema.cid)) {
540
+ const s = classSchema(db, cid);
541
+ add([...s.fields, ...s.overlay]);
542
+ }
543
+ return ids;
544
+ }
545
+
546
+ function columnExpr(db, schema, key, args) {
547
+ if (COLUMNS.has(key))
548
+ return `o.${key}`;
549
+ if (!knownFields(db, schema).has(key))
550
+ throw new DataError(`${schema.label}: нет поля «${key}»`);
551
+ const jsonPath = '$.' + key.split('.').map(p => '"' + p.replace(/"/g, '\\"') + '"').join('.');
552
+ args.push(jsonPath);
553
+ return 'json_extract(o.body, ?)';
554
+ }
555
+
556
+ function ftsQuery(text) {
557
+ return String(text).trim().split(/\s+/).filter(Boolean).slice(0, 12)
558
+ .map(w => '"' + w.replace(/"/g, '""') + '"*').join(' ');
559
+ }
560
+
561
+ function sqlLowerCaseVariants(token) {
562
+ const t = String(token || '').trim();
563
+ if (!t)
564
+ return [];
565
+ const low = t.toLowerCase();
566
+ const up = t.toUpperCase();
567
+ const cap = low.charAt(0).toUpperCase() + low.slice(1);
568
+ return [...new Set([t, low, up, cap])];
569
+ }
570
+
571
+ /**
572
+ * Выборка объектов класса (по умолчанию — с потомками).
573
+ * @param {object} q
574
+ * @param {string} q.cid Класс
575
+ * @param {boolean} [q.deep=true] Включать объекты классов-потомков
576
+ * @param {string|string[]} [q.status] Статусы (по умолчанию — все, кроме deleted)
577
+ * @param {object} [q.where] { поле: значение | { eq|ne|gt|gte|lt|lte|in|contains: значение } }
578
+ * @param {string} [q.search] Полнотекстовый поиск (префиксы слов)
579
+ * @param {*} [q.from] Начало периода по дате объекта
580
+ * @param {*} [q.to] Конец периода (день — включительно)
581
+ * @param {string[]} [q.units] Только эти подразделения
582
+ * @param {string} [q.unit] Подразделение с вложенными
583
+ * @param {Array<object>|null} [q.visible] Права видимости (scope.js, scopeGrants): null — без ограничений
584
+ * @param {string} [q.author] Только автор
585
+ * @param {string} [q.ext_of] Записи-расширения объекта
586
+ * @param {string} [q.order='-time'] Поле или колонка; «-» — по убыванию
587
+ * @param {number} [q.limit=50]
588
+ * @param {number} [q.offset=0]
589
+ * @returns {{ total: number, items: object[] }}
590
+ */
591
+ export function queryObjects(db, q) {
592
+ const schema = classSchema(db, q.cid);
593
+ if (!schema)
594
+ throw new DataError(`класс ${q.cid} не найден`);
595
+ const where = [];
596
+ const args = [];
597
+ if (q.deep === false) {
598
+ where.push('o.cid = ?');
599
+ args.push(schema.cid);
600
+ }
601
+ else {
602
+ where.push('o.cid IN (SELECT descendant FROM class_tree WHERE ancestor = ?)');
603
+ args.push(schema.cid);
604
+ }
605
+ const statuses = q.status === undefined ? null : [].concat(q.status);
606
+ if (statuses) {
607
+ where.push(`o.status IN (${statuses.map(() => '?').join(',')})`);
608
+ args.push(...statuses);
609
+ }
610
+ else
611
+ where.push(`o.status <> 'deleted'`);
612
+ const [from, to] = periodRange(q.from, q.to);
613
+ if (from > -Infinity) {
614
+ where.push('o.time >= ?');
615
+ args.push(from);
616
+ }
617
+ if (to < Infinity) {
618
+ where.push('o.time < ?');
619
+ args.push(to);
620
+ }
621
+ if (Array.isArray(q.units)) {
622
+ where.push(q.units.length ? `o.unit IN (${q.units.map(() => '?').join(',')})` : '0');
623
+ args.push(...q.units);
624
+ }
625
+ if (!isEmpty(q.unit)) {
626
+ const u = getClass(db, q.unit);
627
+ if (!u || FAMILY[u.type] !== 'structure')
628
+ throw new DataError(`подразделение ${q.unit} не найдено`);
629
+ where.push('o.unit IN (SELECT descendant FROM class_tree WHERE ancestor = ?)');
630
+ args.push(u.cid);
631
+ }
632
+ const visible = objectCond(q.visible);
633
+ if (visible) {
634
+ where.push(visible[0]);
635
+ args.push(...visible[1]);
636
+ }
637
+ if (!isEmpty(q.author)) {
638
+ where.push('o.author = ?');
639
+ args.push(String(q.author));
640
+ }
641
+ if (!isEmpty(q.ext_of)) {
642
+ where.push('o.ext_of = ?');
643
+ args.push(String(q.ext_of));
644
+ }
645
+ if (!isEmpty(q.search)) {
646
+ const rawSearch = String(q.search).trim();
647
+ const fts = ftsQuery(rawSearch);
648
+ const tokens = rawSearch.split(/\s+/).filter(Boolean).slice(0, 8);
649
+ const tokenConds = [];
650
+ const likeArgs = [];
651
+ for (const tok of tokens) {
652
+ const variants = sqlLowerCaseVariants(tok);
653
+ const subOr = [];
654
+ for (const v of variants) {
655
+ const pat = '%' + v.replace(/[\\%_]/g, m => '\\' + m) + '%';
656
+ subOr.push(`o.body LIKE ? ESCAPE '\\'`);
657
+ likeArgs.push(pat);
658
+ subOr.push(`EXISTS (SELECT 1 FROM refs rf JOIN objects ro ON ro.id = rf.ref_id WHERE rf.obj_id = o.id AND ro.body LIKE ? ESCAPE '\\')`);
659
+ likeArgs.push(pat);
660
+ }
661
+ subOr.push(`o.id LIKE ? ESCAPE '\\'`);
662
+ likeArgs.push('%' + tok.replace(/[\\%_]/g, m => '\\' + m) + '%');
663
+ tokenConds.push(`(${subOr.join(' OR ')})`);
664
+ }
665
+ const deepSql = tokenConds.length ? tokenConds.join(' AND ') : '0';
666
+ if (fts) {
667
+ where.push(`(o.rowid IN (SELECT rowid FROM objects_fts WHERE objects_fts MATCH ?) OR (${deepSql}))`);
668
+ args.push(fts, ...likeArgs);
669
+ }
670
+ else if (tokenConds.length) {
671
+ where.push(`(${deepSql})`);
672
+ args.push(...likeArgs);
673
+ }
674
+ }
675
+ for (const [key, cond] of Object.entries(q.where || {})) {
676
+ const c = cond !== null && typeof cond === 'object' && !Array.isArray(cond) ? cond : { eq: cond };
677
+ for (const [op, value] of Object.entries(c)) {
678
+ const col = columnExpr(db, schema, key, args);
679
+ if (OPS[op]) {
680
+ where.push(`${col} ${OPS[op]} ?`);
681
+ args.push(value);
682
+ }
683
+ else if (op === 'in') {
684
+ const list = [].concat(value);
685
+ where.push(list.length ? `${col} IN (${list.map(() => '?').join(',')})` : '0');
686
+ args.push(...list);
687
+ }
688
+ else if (op === 'contains') {
689
+ where.push(`${col} LIKE ? ESCAPE '\\'`);
690
+ args.push('%' + String(value).replace(/[\\%_]/g, m => '\\' + m) + '%');
691
+ }
692
+ else
693
+ throw new DataError(`условие «${op}» не поддерживается (eq, ne, gt, gte, lt, lte, in, contains)`);
694
+ }
695
+ }
696
+ const orderRaw = String(q.order || '-time');
697
+ const desc = orderRaw.startsWith('-');
698
+ const orderArgs = [];
699
+ const orderCol = columnExpr(db, schema, desc ? orderRaw.slice(1) : orderRaw, orderArgs);
700
+ const sqlWhere = where.join(' AND ');
701
+ const total = db.prepare(`SELECT count(*) n FROM objects o WHERE ${sqlWhere}`).get(...args).n;
702
+ const limit = Math.max(1, Math.min(MAX_LIMIT, Number(q.limit) || 50));
703
+ const offset = Math.max(0, Number(q.offset) || 0);
704
+ const rows = db.prepare(`${SELECT.replace('FROM objects', 'FROM objects o')} WHERE ${sqlWhere}
705
+ ORDER BY ${orderCol} ${desc ? 'DESC' : 'ASC'}, o.id ${desc ? 'DESC' : 'ASC'} LIMIT ? OFFSET ?`)
706
+ .all(...args, ...orderArgs, limit, offset);
707
+ return { total, items: rows.map(r => toObject(r, classSchema(db, r.cid))) };
708
+ }
709
+
710
+ /* ---------- подшивка документов (связи «Основание → Следствие» между операциями) ---------- */
711
+
712
+ function formatBinderItem(db, obj, direction, { note = '', viaRef = false } = {}) {
713
+ if (!obj)
714
+ return null;
715
+ const cls = getClass(db, obj.cid);
716
+ const b = obj.body || {};
717
+ const num = String(b.num ?? b.doc_number ?? b.order_number ?? b.number ?? b.doc_num ?? '').trim();
718
+ const sum = Number(b.sum ?? b.amount ?? b.total ?? 0) || 0;
719
+ let party = String(b.recipient_name ?? b.payer_name ?? b.counterparty_name ?? '').trim();
720
+ if (!party) {
721
+ for (const k of ['recipient', 'payer', 'counterparty', 'client', 'supplier', 'customer', 'employee', 'person']) {
722
+ if (b[k]) {
723
+ const refObj = readObject(db, b[k]);
724
+ if (refObj?.title) {
725
+ party = refObj.title;
726
+ break;
727
+ }
728
+ }
729
+ }
730
+ }
731
+ return {
732
+ id: obj.id,
733
+ cid: obj.cid,
734
+ path: cls?.path || obj.cid,
735
+ classLabel: cls?.label || obj.cid,
736
+ title: obj.title || cls?.label || obj.id,
737
+ number: num,
738
+ time: obj.time,
739
+ date: localDay(obj.time),
740
+ status: obj.status,
741
+ sum,
742
+ party,
743
+ note: String(note || ''),
744
+ viaRef: Boolean(viaRef),
745
+ direction,
746
+ };
747
+ }
748
+
749
+ /**
750
+ * Получить подшивку операции `id`:
751
+ * - `parents` — документы-основания (от которых произошла операция `id`),
752
+ * - `children` — документы-следствия (созданные на основании операции `id`).
753
+ */
754
+ export function getDocumentBinder(db, id) {
755
+ const docId = String(id ?? '').trim();
756
+ if (!docId)
757
+ return { parents: [], children: [], total: 0 };
758
+ const cur = readObject(db, docId);
759
+ if (!cur)
760
+ throw new DataError(`объект ${docId} не найден`);
761
+
762
+ const parentMap = new Map();
763
+ const childMap = new Map();
764
+
765
+ // 1. Явные связи из таблицы doc_links
766
+ const parentLinks = db.prepare('SELECT source_id, kind, note, created FROM doc_links WHERE target_id = ? ORDER BY created ASC').all(docId);
767
+ for (const row of parentLinks) {
768
+ const src = readObject(db, row.source_id);
769
+ if (src && src.status !== 'deleted')
770
+ parentMap.set(src.id, formatBinderItem(db, src, 'parent', { note: row.note, viaRef: false }));
771
+ }
772
+
773
+ const childLinks = db.prepare('SELECT target_id, kind, note, created FROM doc_links WHERE source_id = ? ORDER BY created ASC').all(docId);
774
+ for (const row of childLinks) {
775
+ const tgt = readObject(db, row.target_id);
776
+ if (tgt && tgt.status !== 'deleted')
777
+ childMap.set(tgt.id, formatBinderItem(db, tgt, 'child', { note: row.note, viaRef: false }));
778
+ }
779
+
780
+ // 2. Прямые ссылки между операциями через поля типа Link (таблица refs)
781
+ const outRefs = db.prepare(`SELECT DISTINCT r.ref_id, r.field FROM refs r JOIN objects o ON o.id = r.ref_id
782
+ WHERE r.obj_id = ? AND o.status <> 'deleted'`).all(docId);
783
+ for (const r of outRefs) {
784
+ if (parentMap.has(r.ref_id))
785
+ continue;
786
+ const refObj = readObject(db, r.ref_id);
787
+ if (!refObj)
788
+ continue;
789
+ const refSchema = classSchema(db, refObj.cid);
790
+ if (refSchema?.family === 'operation')
791
+ parentMap.set(refObj.id, formatBinderItem(db, refObj, 'parent', { note: r.field, viaRef: true }));
792
+ }
793
+
794
+ const inRefs = db.prepare(`SELECT DISTINCT r.obj_id, r.field FROM refs r JOIN objects o ON o.id = r.obj_id
795
+ WHERE r.ref_id = ? AND o.status <> 'deleted'`).all(docId);
796
+ for (const r of inRefs) {
797
+ if (childMap.has(r.obj_id))
798
+ continue;
799
+ const refObj = readObject(db, r.obj_id);
800
+ if (!refObj)
801
+ continue;
802
+ const refSchema = classSchema(db, refObj.cid);
803
+ if (refSchema?.family === 'operation')
804
+ childMap.set(refObj.id, formatBinderItem(db, refObj, 'child', { note: r.field, viaRef: true }));
805
+ }
806
+
807
+ const parents = [...parentMap.values()].filter(Boolean).sort((a, b) => (a.time || 0) - (b.time || 0));
808
+ const children = [...childMap.values()].filter(Boolean).sort((a, b) => (a.time || 0) - (b.time || 0));
809
+ return {
810
+ parents,
811
+ children,
812
+ total: parents.length + children.length,
813
+ };
814
+ }
815
+
816
+ /** Связать две операции в подшивке (`sourceId` — основание, `targetId` — следствие). */
817
+ export function linkDocuments(db, { sourceId, targetId, kind = 'basis', note = '', author = null } = {}) {
818
+ const srcId = String(sourceId ?? '').trim();
819
+ const tgtId = String(targetId ?? '').trim();
820
+ if (!srcId || !tgtId)
821
+ throw new DataError('не указаны связываемые документы');
822
+ if (srcId === tgtId)
823
+ throw new DataError('нельзя подшить документ к самому себе');
824
+ const src = readObject(db, srcId);
825
+ const tgt = readObject(db, tgtId);
826
+ if (!src || src.status === 'deleted')
827
+ throw new DataError(`документ-основание ${srcId} не найден`);
828
+ if (!tgt || tgt.status === 'deleted')
829
+ throw new DataError(`документ-следствие ${tgtId} не найден`);
830
+ tx(db, () => {
831
+ // Если была обратная связь — удалим её, чтобы не образовывать петлю из 2 документов
832
+ db.prepare('DELETE FROM doc_links WHERE source_id = ? AND target_id = ?').run(tgtId, srcId);
833
+ db.prepare(`INSERT INTO doc_links (source_id, target_id, kind, note, author, created)
834
+ VALUES (?, ?, ?, ?, ?, ?)
835
+ ON CONFLICT(source_id, target_id) DO UPDATE SET kind = excluded.kind, note = excluded.note`)
836
+ .run(srcId, tgtId, String(kind || 'basis'), String(note || ''), author ?? null, Date.now());
837
+ });
838
+ return { ok: true };
839
+ }
840
+
841
+ /** Разорвать связь между двумя операциями в подшивке. */
842
+ export function unlinkDocuments(db, { id, targetId } = {}) {
843
+ const a = String(id ?? '').trim();
844
+ const b = String(targetId ?? '').trim();
845
+ if (!a || !b)
846
+ throw new DataError('не указаны документы для открепления');
847
+ db.prepare('DELETE FROM doc_links WHERE (source_id = ? AND target_id = ?) OR (source_id = ? AND target_id = ?)').run(a, b, b, a);
848
+ return { ok: true };
849
+ }
850
+
851
+ /**
852
+ * Подобрать кандидатов-операций для подшивки к документу `id`:
853
+ * в первую очередь показывает документы по тому же контрагенту/сотруднику, затем по поиску и дате.
854
+ */
855
+ export function candidateBinderOperations(db, id, { search = '', samePartyOnly = false, limit = 50 } = {}) {
856
+ const docId = String(id ?? '').trim();
857
+ const cur = docId ? readObject(db, docId) : null;
858
+ const opClasses = db.prepare("SELECT cid FROM classes WHERE type = '$operation'").all().map(r => r.cid);
859
+ if (!opClasses.length)
860
+ return { items: [], partyIds: [], partyName: '' };
861
+
862
+ const existingIds = new Set([docId]);
863
+ if (docId) {
864
+ const binder = getDocumentBinder(db, docId);
865
+ for (const p of binder.parents)
866
+ existingIds.add(p.id);
867
+ for (const c of binder.children)
868
+ existingIds.add(c.id);
869
+ }
870
+
871
+ const partyIds = [];
872
+ let partyName = '';
873
+ if (cur) {
874
+ const refs = db.prepare('SELECT DISTINCT ref_id FROM refs WHERE obj_id = ?').all(docId).map(r => r.ref_id);
875
+ for (const rid of refs) {
876
+ const ro = readObject(db, rid);
877
+ if (ro && classSchema(db, ro.cid)?.family === 'catalog') {
878
+ partyIds.push(rid);
879
+ if (!partyName && ro.title)
880
+ partyName = ro.title;
881
+ }
882
+ }
883
+ if (!partyName) {
884
+ const b = cur.body || {};
885
+ partyName = String(b.recipient_name ?? b.payer_name ?? b.counterparty_name ?? '').trim();
886
+ }
887
+ }
888
+
889
+ const where = [
890
+ `o.cid IN (${opClasses.map(() => '?').join(',')})`,
891
+ `o.status <> 'deleted'`,
892
+ ];
893
+ const args = [...opClasses];
894
+ if (docId) {
895
+ where.push('o.id <> ?');
896
+ args.push(docId);
897
+ }
898
+
899
+ if (samePartyOnly && partyIds.length) {
900
+ where.push(`EXISTS (SELECT 1 FROM refs rf WHERE rf.obj_id = o.id AND rf.ref_id IN (${partyIds.map(() => '?').join(',')}))`);
901
+ args.push(...partyIds);
902
+ }
903
+
904
+ const q = String(search ?? '').trim();
905
+ if (q) {
906
+ const tokens = q.split(/\s+/).filter(Boolean).slice(0, 6);
907
+ for (const tok of tokens) {
908
+ const variants = sqlLowerCaseVariants(tok);
909
+ const sub = [];
910
+ for (const v of variants) {
911
+ const pat = '%' + v.replace(/[\\%_]/g, m => '\\' + m) + '%';
912
+ sub.push(`o.body LIKE ? ESCAPE '\\'`);
913
+ args.push(pat);
914
+ sub.push(`EXISTS (SELECT 1 FROM refs rf JOIN objects ro ON ro.id = rf.ref_id WHERE rf.obj_id = o.id AND ro.body LIKE ? ESCAPE '\\')`);
915
+ args.push(pat);
916
+ sub.push(`EXISTS (SELECT 1 FROM classes cl WHERE cl.cid = o.cid AND cl.label LIKE ? ESCAPE '\\')`);
917
+ args.push(pat);
918
+ }
919
+ where.push(`(${sub.join(' OR ')})`);
920
+ }
921
+ }
922
+
923
+ const maxRows = Math.max(10, Math.min(200, Number(limit) || 50));
924
+ let orderSql = 'o.time DESC, o.id DESC';
925
+ const orderArgs = [];
926
+ if (partyIds.length) {
927
+ orderSql = `CASE WHEN EXISTS (SELECT 1 FROM refs rf WHERE rf.obj_id = o.id AND rf.ref_id IN (${partyIds.map(() => '?').join(',')})) THEN 0 ELSE 1 END ASC, ABS(o.time - ?) ASC, o.time DESC`;
928
+ orderArgs.push(...partyIds, cur?.time || Date.now());
929
+ }
930
+ else if (cur?.time) {
931
+ orderSql = `ABS(o.time - ?) ASC, o.time DESC`;
932
+ orderArgs.push(cur.time);
933
+ }
934
+
935
+ const rows = db.prepare(`${SELECT.replace('FROM objects', 'FROM objects o')} WHERE ${where.join(' AND ')} ORDER BY ${orderSql} LIMIT ?`)
936
+ .all(...args, ...orderArgs, maxRows);
937
+
938
+ const items = [];
939
+ for (const r of rows) {
940
+ if (existingIds.has(r.id))
941
+ continue;
942
+ const obj = toObject(r, classSchema(db, r.cid));
943
+ const formatted = formatBinderItem(db, obj, 'candidate');
944
+ if (formatted) {
945
+ formatted.sameParty = partyIds.length > 0 && db.prepare(`SELECT 1 FROM refs WHERE obj_id = ? AND ref_id IN (${partyIds.map(() => '?').join(',')}) LIMIT 1`).get(r.id, ...partyIds) != null;
946
+ items.push(formatted);
947
+ }
948
+ }
949
+ return { items, partyIds, partyName };
950
+ }