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.
- package/#system/certs/russian_trusted_root_ca.pem +33 -0
- package/#system/mail.json.example +9 -0
- package/#system/package-manifest.json +986 -0
- package/#system/sandbox.json +19 -0
- package/#system/settings.json.example +3 -0
- package/package.json +1 -1
- package/run.mjs +78 -11
- package/scripts/check-ignored-code.mjs +25 -1
- package/sources/data/api.js +1071 -0
- package/sources/data/choices.js +125 -0
- package/sources/data/classes.js +412 -0
- package/sources/data/db.js +383 -0
- package/sources/data/errors.js +15 -0
- package/sources/data/expr.js +456 -0
- package/sources/data/objects.js +950 -0
- package/sources/data/posting.js +657 -0
- package/sources/data/print.js +524 -0
- package/sources/data/progress.md +526 -0
- package/sources/data/readme.md +77 -0
- package/sources/data/reports.js +1033 -0
- package/sources/data/schema.js +586 -0
- package/sources/data/scope.js +281 -0
- package/sources/data/sql-inspector.js +892 -0
- package/sources/data/time.js +67 -0
- package/sources/data/workspace.js +152 -0
- package/sources/host/auto-updater.js +52 -10
- package/.vscode/launch.json +0 -32
- package/.vscode/settings.json +0 -2
- package/.vscode/tasks.json +0 -65
- package/ORGANIZATIONS//320/221/320/270/320/267/320/275/320/265/321/201 /320/230/320/275/321/202/320/265/321/200 /320/241/320/276/321/204/321/202/orders/$structure/video/IMG_8524.MP4 +0 -0
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Проведение операций: проводки единого журнала по правилам класса.
|
|
3
|
+
*
|
|
4
|
+
* Правило (POSTINGS, собирается по наследованию в schema.js):
|
|
5
|
+
* { id, each?, when?, amount?, qty?, unit?, item?, qty2?,
|
|
6
|
+
* debit: { account, analytics: { слот: выражение } }, credit: { ... } }
|
|
7
|
+
* - each — табличная часть: проводка на каждую строку (в выражениях `row`);
|
|
8
|
+
* - when — условие: ложь — проводки нет;
|
|
9
|
+
* - amount — сумма в рублях (хранится в копейках);
|
|
10
|
+
* - qty — количество в единице документа; с `unit` и `item` пересчитывается
|
|
11
|
+
* в базовую единицу номенклатуры (её `base_unit` и табличная часть
|
|
12
|
+
* `units`: { unit, coef }); в проводке — базовое, а также doc_qty/doc_unit;
|
|
13
|
+
* - qty2 — второе измерение (масса при учёте в литрах и т. п.);
|
|
14
|
+
* - аналитики счёта — выражения, дающие id объекта целевого класса слота.
|
|
15
|
+
* Строка, где сумма и количества нулевые, проводки не даёт.
|
|
16
|
+
*
|
|
17
|
+
* Функции выражений: norm(вид, объект?, поУмолчанию?) — норматив на дату
|
|
18
|
+
* операции (остаток количества счёта нормативов, см. normValue);
|
|
19
|
+
* coef(номенклатура, единица) — коэффициент единицы к базовой;
|
|
20
|
+
* unit_cost(счёт, номенклатура, склад?, партия?, поУмолчанию?) — скользящая средняя
|
|
21
|
+
* стоимость базовой единицы остатка на дату операции (см. unitCost).
|
|
22
|
+
*
|
|
23
|
+
* Автоаналитики проводки — колонки: операция, её класс, автор действия,
|
|
24
|
+
* рабочее место и подразделение операции.
|
|
25
|
+
*
|
|
26
|
+
* Жизненный цикл (docs/architecture-v2.md, Р1): регистрация — проводки в той же
|
|
27
|
+
* транзакции; исправление — новая версия, сторно действующих проводок и новые
|
|
28
|
+
* проводки; отмена — сторно. Дата в закрытом периоде: регистрировать и переносить
|
|
29
|
+
* туда нельзя, а сторно и новые проводки исправления датируются текущим моментом.
|
|
30
|
+
*/
|
|
31
|
+
import { tx, getSetting, setSetting } from './db.js';
|
|
32
|
+
import { getClass, descendants, classesOfType, classesGeneration } from './classes.js';
|
|
33
|
+
import { classSchema, checkSchema, analyticSlots, classBasePath, resolveInBase } from './schema.js';
|
|
34
|
+
import { compile, toNumber, round, ExprError } from './expr.js';
|
|
35
|
+
import { readObject, writeVersion, validateBody, linkCids, mergeBody } from './objects.js';
|
|
36
|
+
import { DataError } from './errors.js';
|
|
37
|
+
import { parseTime, localDay, dayStart, nextDay } from './time.js';
|
|
38
|
+
|
|
39
|
+
const isEmpty = v => v === undefined || v === null || v === '';
|
|
40
|
+
const num = v => toNumber(v, msg => { throw new DataError(msg); });
|
|
41
|
+
|
|
42
|
+
/* ---------- закрытый период ---------- */
|
|
43
|
+
|
|
44
|
+
const CLOSED_KEY = 'closed_until';
|
|
45
|
+
|
|
46
|
+
/** Последний закрытый день 'YYYY-MM-DD' (включительно) или null. */
|
|
47
|
+
export function closedUntil(db) {
|
|
48
|
+
return getSetting(db, CLOSED_KEY) ?? null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Момент попадает в закрытый период. */
|
|
52
|
+
export function isClosed(db, time) {
|
|
53
|
+
const day = closedUntil(db);
|
|
54
|
+
return !!day && time < nextDay(dayStart(day));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Закрыть период по день включительно (null — открыть всё).
|
|
59
|
+
* Закрыть можно только прошедший день: исправления в закрытом периоде датируются сегодняшним.
|
|
60
|
+
*/
|
|
61
|
+
export function closePeriod(db, day) {
|
|
62
|
+
if (isEmpty(day)) {
|
|
63
|
+
setSetting(db, CLOSED_KEY, null);
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
const s = String(day).trim();
|
|
67
|
+
if (nextDay(dayStart(s)) > Date.now())
|
|
68
|
+
throw new DataError(`закрыть можно только прошедший день, а не ${s}`);
|
|
69
|
+
setSetting(db, CLOSED_KEY, s);
|
|
70
|
+
return s;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function closedError(db, what) {
|
|
74
|
+
return new DataError(`период по ${closedUntil(db)} закрыт — ${what}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* ---------- нормативы ---------- */
|
|
78
|
+
|
|
79
|
+
const normsCache = new WeakMap();
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Счёт нормативов: класс счёта с `NORMS: true` или `NORMS: { kind, object }` —
|
|
83
|
+
* имена слотов-аналитик вида норматива и объекта (по умолчанию 'kind' и 'object').
|
|
84
|
+
*/
|
|
85
|
+
function normsAccount(db) {
|
|
86
|
+
const gen = classesGeneration(db);
|
|
87
|
+
const hit = normsCache.get(db);
|
|
88
|
+
if (hit && hit.gen === gen)
|
|
89
|
+
return hit.acc;
|
|
90
|
+
let list = classesOfType(db, '$account').filter(c => c.own?.NORMS || classSchema(db, c.cid)?.norms);
|
|
91
|
+
const nonModule = list.filter(c => !c.path.startsWith('/MODULES/'));
|
|
92
|
+
if (nonModule.length)
|
|
93
|
+
list = nonModule;
|
|
94
|
+
// Внутри одного журнала ($register) счёт нормативов должен быть единственным
|
|
95
|
+
const byRegister = new Map();
|
|
96
|
+
for (const c of list) {
|
|
97
|
+
const reg = c.parent || '';
|
|
98
|
+
const prev = byRegister.get(reg) || [];
|
|
99
|
+
prev.push(c);
|
|
100
|
+
if (prev.length > 1)
|
|
101
|
+
throw new DataError(`счетов нормативов несколько: ${prev.map(x => x.path).join(', ')} — NORMS должен быть у одного`);
|
|
102
|
+
byRegister.set(reg, prev);
|
|
103
|
+
}
|
|
104
|
+
let acc = null;
|
|
105
|
+
if (list.length) {
|
|
106
|
+
const c = list[0];
|
|
107
|
+
const rawNorms = c.own?.NORMS ?? classSchema(db, c.cid)?.norms;
|
|
108
|
+
const spec = (rawNorms && typeof rawNorms === 'object') ? rawNorms : {};
|
|
109
|
+
const kind = String(spec.kind || 'kind');
|
|
110
|
+
const allCids = new Set();
|
|
111
|
+
const allKindCids = new Set();
|
|
112
|
+
for (const item of list) {
|
|
113
|
+
for (const cid of descendants(db, item.cid))
|
|
114
|
+
allCids.add(cid);
|
|
115
|
+
const slot = analyticSlots(classSchema(db, item.cid)).find(f => f.id === kind);
|
|
116
|
+
for (const kcid of (slot ? linkCids(db, slot.target) ?? [] : []))
|
|
117
|
+
allKindCids.add(kcid);
|
|
118
|
+
}
|
|
119
|
+
acc = {
|
|
120
|
+
cid: c.cid,
|
|
121
|
+
label: c.label,
|
|
122
|
+
cids: [...allCids],
|
|
123
|
+
kind,
|
|
124
|
+
object: String(spec.object || 'object'),
|
|
125
|
+
kindCids: allKindCids.size ? [...allKindCids] : null,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
normsCache.set(db, { gen, acc });
|
|
129
|
+
return acc;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function resolveKind(db, acc, kind) {
|
|
133
|
+
const key = String(typeof kind === 'object' && kind ? kind.id : kind ?? '').trim();
|
|
134
|
+
if (!key)
|
|
135
|
+
throw new DataError('norm(): не задан вид норматива');
|
|
136
|
+
if (!acc.kindCids)
|
|
137
|
+
throw new DataError(`у счёта нормативов ${acc.label} нет аналитики «${acc.kind}»`);
|
|
138
|
+
const marks = acc.kindCids.map(() => '?').join(',') || "''";
|
|
139
|
+
const byId = db.prepare(`SELECT id FROM objects WHERE id = ? AND cid IN (${marks})`).get(key, ...acc.kindCids);
|
|
140
|
+
if (byId)
|
|
141
|
+
return byId.id;
|
|
142
|
+
const byName = db.prepare(`SELECT id FROM objects WHERE cid IN (${marks}) AND status <> 'deleted'
|
|
143
|
+
AND json_extract(body, '$.name') = ? ORDER BY created LIMIT 1`).get(...acc.kindCids, key);
|
|
144
|
+
if (!byName)
|
|
145
|
+
throw new DataError(`вид норматива «${key}» не найден`);
|
|
146
|
+
return byName.id;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Норматив на момент: остаток количества (Дт − Кт) счёта нормативов по виду и объекту.
|
|
151
|
+
* С объектом — сначала его собственный норматив, затем общий (без объекта).
|
|
152
|
+
* С `fallback` поиск только точный, а при отсутствии значения возвращается fallback
|
|
153
|
+
* (так операция «Установка норматива» считает разницу: `value - norm(kind, object, 0)`).
|
|
154
|
+
* @param {object} [opts] { fallback?, exclude? — id операции, чьи проводки не учитываются }
|
|
155
|
+
*/
|
|
156
|
+
export function normValue(db, kind, object, at, { fallback, exclude = '' } = {}) {
|
|
157
|
+
const acc = normsAccount(db);
|
|
158
|
+
if (!acc)
|
|
159
|
+
throw new DataError('счёт нормативов не настроен (NORMS: true в class.js счёта)');
|
|
160
|
+
const kindId = resolveKind(db, acc, kind);
|
|
161
|
+
const moment = parseTime(at ?? Date.now(), 'дата норматива');
|
|
162
|
+
const marks = acc.cids.map(() => '?').join(',');
|
|
163
|
+
const lookup = obj => {
|
|
164
|
+
let n = 0, v = 0;
|
|
165
|
+
for (const [side, col] of [['D', 'debit_cid'], ['C', 'credit_cid']]) {
|
|
166
|
+
const objCond = obj
|
|
167
|
+
? 'EXISTS (SELECT 1 FROM entry_analytics o WHERE o.entry_id = e.id AND o.side = ? AND o.slot = ? AND o.ref_id = ?)'
|
|
168
|
+
: 'NOT EXISTS (SELECT 1 FROM entry_analytics o WHERE o.entry_id = e.id AND o.side = ? AND o.slot = ?)';
|
|
169
|
+
const r = db.prepare(`SELECT coalesce(sum(CASE WHEN e.storno THEN -1 ELSE 1 END), 0) n, coalesce(sum(e.qty), 0) q
|
|
170
|
+
FROM entries e JOIN entry_analytics k ON k.entry_id = e.id AND k.side = ? AND k.slot = ? AND k.ref_id = ?
|
|
171
|
+
WHERE e.${col} IN (${marks}) AND e.time <= ? AND e.op_id <> ? AND ${objCond}`)
|
|
172
|
+
.get(side, acc.kind, kindId, ...acc.cids, moment, String(exclude), side, acc.object, ...(obj ? [obj] : []));
|
|
173
|
+
n += r.n;
|
|
174
|
+
v += side === 'D' ? r.q : -r.q;
|
|
175
|
+
}
|
|
176
|
+
return n > 0 ? round(v, 10) : undefined;
|
|
177
|
+
};
|
|
178
|
+
const objId = isEmpty(object) ? null : String(typeof object === 'object' ? object.id : object);
|
|
179
|
+
if (fallback !== undefined)
|
|
180
|
+
return lookup(objId) ?? fallback;
|
|
181
|
+
const v = (objId ? lookup(objId) : undefined) ?? lookup(null);
|
|
182
|
+
if (v === undefined)
|
|
183
|
+
throw new DataError(`норматив «${kind}» не задан на ${localDay(moment)}`);
|
|
184
|
+
return v;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/* ---------- единицы ---------- */
|
|
188
|
+
|
|
189
|
+
/** Коэффициент единицы к базовой единице номенклатуры (версия объекта на момент). */
|
|
190
|
+
export function unitCoef(db, item, unit, at) {
|
|
191
|
+
const u = String(unit ?? '').trim();
|
|
192
|
+
if (!u)
|
|
193
|
+
return 1;
|
|
194
|
+
const id = String(typeof item === 'object' && item ? item.id : item ?? '');
|
|
195
|
+
if (!id)
|
|
196
|
+
throw new DataError(`единица «${u}» без номенклатуры`);
|
|
197
|
+
const obj = readObject(db, id, { at }) ?? readObject(db, id);
|
|
198
|
+
if (!obj)
|
|
199
|
+
throw new DataError(`номенклатура ${id} не найдена`);
|
|
200
|
+
const same = x => String(x ?? '').trim().toLowerCase() === u.toLowerCase();
|
|
201
|
+
if (same(obj.body.base_unit))
|
|
202
|
+
return 1;
|
|
203
|
+
const row = (Array.isArray(obj.body.units) ? obj.body.units : []).find(r => same(r?.unit));
|
|
204
|
+
if (!row)
|
|
205
|
+
throw new DataError(`у «${obj.title || id}» нет единицы «${u}»`);
|
|
206
|
+
const k = num(row.coef);
|
|
207
|
+
if (!(k > 0))
|
|
208
|
+
throw new DataError(`у «${obj.title || id}» коэффициент единицы «${u}» не положителен`);
|
|
209
|
+
return k;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/* ---------- себестоимость ---------- */
|
|
213
|
+
|
|
214
|
+
const COST_SLOTS = ['item', 'storage', 'batch'];
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Скользящая средняя: стоимость единицы остатка счёта (в базовой единице) на момент операции —
|
|
218
|
+
* сальдо суммы, делённое на сальдо количества. Остаток отбирается по аналитикам счёта
|
|
219
|
+
* `item`, `storage`, `batch` (пустые не учитываются); проводки самой операции не берутся,
|
|
220
|
+
* поэтому исправление считает цену заново. Остатка нет: ошибка, а с `fallback` — он.
|
|
221
|
+
* @param {object} p { account, item, storage?, batch?, at, exclude?, fallback? }
|
|
222
|
+
*/
|
|
223
|
+
export function unitCost(db, { account, item, storage, batch, at, exclude = '', excludeIds = null, fallback } = {}) {
|
|
224
|
+
const cls = getClass(db, account);
|
|
225
|
+
if (!cls || cls.type !== '$account')
|
|
226
|
+
throw new DataError(`unit_cost(): счёт ${account} не найден`);
|
|
227
|
+
const slots = analyticSlots(classSchema(db, cls.cid)).map(f => f.id);
|
|
228
|
+
const want = { item, storage, batch };
|
|
229
|
+
const cond = [], args = [];
|
|
230
|
+
for (const slot of COST_SLOTS) {
|
|
231
|
+
const v = want[slot];
|
|
232
|
+
if (isEmpty(v))
|
|
233
|
+
continue;
|
|
234
|
+
if (!slots.includes(slot))
|
|
235
|
+
throw new DataError(`unit_cost(): у счёта ${cls.label} нет аналитики «${slot}»`);
|
|
236
|
+
cond.push(slot);
|
|
237
|
+
args.push(slot, String(typeof v === 'object' ? v.id : v));
|
|
238
|
+
}
|
|
239
|
+
if (!cond.includes('item'))
|
|
240
|
+
throw new DataError('unit_cost(): не задана номенклатура');
|
|
241
|
+
const moment = parseTime(at ?? Date.now(), 'дата себестоимости');
|
|
242
|
+
const accs = descendants(db, cls.cid);
|
|
243
|
+
const marks = accs.map(() => '?').join(',');
|
|
244
|
+
const exclList = Array.isArray(excludeIds) && excludeIds.length
|
|
245
|
+
? excludeIds.map(String)
|
|
246
|
+
: [String(exclude)];
|
|
247
|
+
const exclMarks = exclList.map(() => '?').join(',');
|
|
248
|
+
let amount = 0, qty = 0, n = 0;
|
|
249
|
+
for (const [side, col, sign] of [['D', 'debit_cid', 1], ['C', 'credit_cid', -1]]) {
|
|
250
|
+
const exists = cond.map(() => 'EXISTS (SELECT 1 FROM entry_analytics a WHERE a.entry_id = e.id AND a.side = ? AND a.slot = ? AND a.ref_id = ?)').join(' AND ');
|
|
251
|
+
const params = [];
|
|
252
|
+
for (let i = 0; i < args.length; i += 2)
|
|
253
|
+
params.push(side, args[i], args[i + 1]);
|
|
254
|
+
const r = db.prepare(`SELECT count(*) n, coalesce(sum(e.amount), 0) a, coalesce(sum(e.qty), 0) q FROM entries e
|
|
255
|
+
WHERE e.${col} IN (${marks}) AND e.time <= ? AND e.op_id NOT IN (${exclMarks}) AND ${exists}`).get(...accs, moment, ...exclList, ...params);
|
|
256
|
+
n += r.n;
|
|
257
|
+
amount += sign * r.a;
|
|
258
|
+
qty += sign * r.q;
|
|
259
|
+
}
|
|
260
|
+
if (n === 0 || Math.abs(qty) < 1e-9) {
|
|
261
|
+
if (fallback !== undefined)
|
|
262
|
+
return fallback;
|
|
263
|
+
throw new DataError(`unit_cost(): остатка нет — себестоимость на ${localDay(moment)} не определить (${cls.label})`);
|
|
264
|
+
}
|
|
265
|
+
return round(amount / 100 / qty, 6);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/* ---------- расчёт проводок ---------- */
|
|
269
|
+
|
|
270
|
+
const rulesCache = new WeakMap();
|
|
271
|
+
|
|
272
|
+
/** Правила класса операции готовы к проведению (checkSchema без ошибок; кэш до изменения классов). */
|
|
273
|
+
function assertRules(db, schema) {
|
|
274
|
+
const gen = classesGeneration(db);
|
|
275
|
+
let byDb = rulesCache.get(db);
|
|
276
|
+
if (!byDb || byDb.gen !== gen) {
|
|
277
|
+
byDb = { gen, map: new Map() };
|
|
278
|
+
rulesCache.set(db, byDb);
|
|
279
|
+
}
|
|
280
|
+
if (!byDb.map.has(schema.cid))
|
|
281
|
+
byDb.map.set(schema.cid, schema.leaf ? checkSchema(db, schema.cid) : [`${schema.path}: операции проводятся только в конечном классе`]);
|
|
282
|
+
const errors = byDb.map.get(schema.cid);
|
|
283
|
+
if (errors.length)
|
|
284
|
+
throw new DataError(`${schema.label}: правила проводок не готовы`, errors.map(message => ({ message })));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function sideOf(db, rule, side, scope, fns) {
|
|
288
|
+
const name = side === 'debit' ? 'дебет' : 'кредит';
|
|
289
|
+
const cls = getClass(db, rule[side]?.account);
|
|
290
|
+
if (!cls)
|
|
291
|
+
throw new DataError(`счёт ${rule[side]?.account} (${name}) не найден`);
|
|
292
|
+
const acc = classSchema(db, cls.cid);
|
|
293
|
+
const analytics = {};
|
|
294
|
+
for (const slot of analyticSlots(acc)) {
|
|
295
|
+
const src = rule[side].analytics?.[slot.id];
|
|
296
|
+
let v = isEmpty(src) ? undefined : compile(src).run(scope, fns);
|
|
297
|
+
if (v && typeof v === 'object' && !Array.isArray(v))
|
|
298
|
+
v = v.id;
|
|
299
|
+
if (isEmpty(v)) {
|
|
300
|
+
if (slot.required)
|
|
301
|
+
throw new DataError(`аналитика «${slot.label}» счёта ${acc.label} (${name}) не заполнена`);
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (Array.isArray(v))
|
|
305
|
+
throw new DataError(`аналитика «${slot.label}» счёта ${acc.label} (${name}) — список, а не один объект`);
|
|
306
|
+
const id = String(v);
|
|
307
|
+
const obj = db.prepare('SELECT cid, status FROM objects WHERE id = ?').get(id);
|
|
308
|
+
const cids = linkCids(db, slot.target);
|
|
309
|
+
if (!obj || !cids?.includes(obj.cid) || obj.status === 'deleted')
|
|
310
|
+
throw new DataError(`аналитика «${slot.label}» счёта ${acc.label} (${name}): объект ${id} не найден в ${getClass(db, slot.target)?.label ?? slot.target}`);
|
|
311
|
+
analytics[slot.id] = { ref_id: id, ref_cid: obj.cid };
|
|
312
|
+
}
|
|
313
|
+
return { cid: cls.cid, analytics };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Проводки операции по правилам класса (без записи).
|
|
318
|
+
* @param {object} op { id, cid, time, body }
|
|
319
|
+
* @returns {{ entries: object[], errors: Array<{field, message}> }} amount — в копейках
|
|
320
|
+
*/
|
|
321
|
+
function computeEntries(db, schema, op, { excludeIds = null } = {}) {
|
|
322
|
+
const entries = [], errors = [];
|
|
323
|
+
const basePath = classBasePath(db, schema.cid);
|
|
324
|
+
const fns = {
|
|
325
|
+
norm: (...a) => normValue(db, a[0], a[1], op.time, { exclude: op.id, ...(a.length >= 3 ? { fallback: a[2] } : {}) }),
|
|
326
|
+
coef: (item, unit) => unitCoef(db, item, unit, op.time),
|
|
327
|
+
unit_cost: (account, item, storage, batch, ...rest) => unitCost(db, {
|
|
328
|
+
account: basePath ? resolveInBase(db, basePath, account) : account,
|
|
329
|
+
item, storage, batch, at: op.time, exclude: op.id, excludeIds, ...(rest.length ? { fallback: rest[0] } : {}),
|
|
330
|
+
}),
|
|
331
|
+
};
|
|
332
|
+
for (const rule of schema.postings) {
|
|
333
|
+
const rows = rule.each ? (Array.isArray(op.body[rule.each]) ? op.body[rule.each] : []) : [null];
|
|
334
|
+
rows.forEach((row, i) => {
|
|
335
|
+
const where = rule.each ? `правило «${rule.id}», строка ${i + 1}` : `правило «${rule.id}»`;
|
|
336
|
+
try {
|
|
337
|
+
const scope = rule.each ? { ...op.body, row } : { ...op.body };
|
|
338
|
+
const ev = key => (isEmpty(rule[key]) ? undefined : compile(rule[key]).run(scope, fns));
|
|
339
|
+
if (!isEmpty(rule.when) && !ev('when'))
|
|
340
|
+
return;
|
|
341
|
+
const amount = Math.round(round(num(ev('amount')), 2) * 100);
|
|
342
|
+
const docQty = num(ev('qty'));
|
|
343
|
+
const unit = isEmpty(rule.unit) ? '' : String(ev('unit') ?? '').trim();
|
|
344
|
+
const qty = round(unit ? docQty * unitCoef(db, ev('item'), unit, op.time) : docQty, 6);
|
|
345
|
+
const qty2 = round(num(ev('qty2')), 6);
|
|
346
|
+
if (!amount && !qty && !qty2)
|
|
347
|
+
return;
|
|
348
|
+
const debit = sideOf(db, rule, 'debit', scope, fns);
|
|
349
|
+
const credit = sideOf(db, rule, 'credit', scope, fns);
|
|
350
|
+
entries.push({
|
|
351
|
+
rule: rule.id,
|
|
352
|
+
row: rule.each ? i : -1,
|
|
353
|
+
debit_cid: debit.cid,
|
|
354
|
+
credit_cid: credit.cid,
|
|
355
|
+
amount,
|
|
356
|
+
qty,
|
|
357
|
+
qty2,
|
|
358
|
+
doc_qty: unit ? docQty : null,
|
|
359
|
+
doc_unit: unit || null,
|
|
360
|
+
analytics: { D: debit.analytics, C: credit.analytics },
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
catch (e) {
|
|
364
|
+
if (!(e instanceof DataError || e instanceof ExprError))
|
|
365
|
+
throw e;
|
|
366
|
+
errors.push({ field: where, message: e.message });
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
return { entries, errors };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function insertEntries(db, op, entries, { time, version, author, storno = false, now }) {
|
|
374
|
+
const ins = db.prepare(`INSERT INTO entries (op_id, op_version, op_cid, rule, row, time, debit_cid, credit_cid,
|
|
375
|
+
amount, qty, qty2, doc_qty, doc_unit, author, place, unit, storno, created)
|
|
376
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
377
|
+
const insA = db.prepare('INSERT INTO entry_analytics (entry_id, side, slot, ref_id, ref_cid) VALUES (?, ?, ?, ?, ?)');
|
|
378
|
+
const insAcc = db.prepare(`INSERT INTO account_entries (entry_id, side, account_cid, corr_cid, op_id, op_version, op_cid,
|
|
379
|
+
rule, row, time, debit, credit, qty_in, qty_out, qty2, doc_qty, doc_unit, analytics, author, place, unit, storno, created)
|
|
380
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
381
|
+
for (const e of entries) {
|
|
382
|
+
const sign = storno ? -1 : 1;
|
|
383
|
+
const entryTime = e.time ?? time;
|
|
384
|
+
const entryAuthor = author ?? op.author ?? null;
|
|
385
|
+
const amt = sign * e.amount;
|
|
386
|
+
const q = sign * e.qty;
|
|
387
|
+
const q2 = sign * e.qty2;
|
|
388
|
+
const docQty = e.doc_qty === null ? null : sign * e.doc_qty;
|
|
389
|
+
const stornoFlag = storno ? 1 : 0;
|
|
390
|
+
const res = ins.run(op.id, version, op.cid, e.rule, e.row, entryTime, e.debit_cid, e.credit_cid,
|
|
391
|
+
amt, q, q2, docQty, e.doc_unit, entryAuthor, op.place ?? null, op.unit ?? null, stornoFlag, now);
|
|
392
|
+
const id = Number(res.lastInsertRowid);
|
|
393
|
+
const sideJson = side => JSON.stringify(Object.fromEntries(Object.entries(e.analytics[side] || {}).map(([slot, a]) => [slot, a.ref_id])));
|
|
394
|
+
// Явная двойная запись: отдельная запись в счёте Дебета и отдельная запись в счёте Кредита
|
|
395
|
+
insAcc.run(id, 'D', e.debit_cid, e.credit_cid, op.id, version, op.cid, e.rule, e.row, entryTime,
|
|
396
|
+
amt, 0, q, 0, q2, docQty, e.doc_unit, sideJson('D'), entryAuthor, op.place ?? null, op.unit ?? null, stornoFlag, now);
|
|
397
|
+
insAcc.run(id, 'C', e.credit_cid, e.debit_cid, op.id, version, op.cid, e.rule, e.row, entryTime,
|
|
398
|
+
0, amt, 0, q, q2, docQty, e.doc_unit, sideJson('C'), entryAuthor, op.place ?? null, op.unit ?? null, stornoFlag, now);
|
|
399
|
+
for (const side of ['D', 'C']) {
|
|
400
|
+
for (const [slot, a] of Object.entries(e.analytics[side]))
|
|
401
|
+
insA.run(id, side, slot, a.ref_id, a.ref_cid);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Действующие проводки операции: последней версии проведения, без сторно (amount в копейках). */
|
|
407
|
+
function effectiveRows(db, opId) {
|
|
408
|
+
const rows = db.prepare(`SELECT * FROM entries WHERE op_id = ? AND storno = 0
|
|
409
|
+
AND op_version = (SELECT max(op_version) FROM entries WHERE op_id = ?) ORDER BY id`).all(opId, opId);
|
|
410
|
+
return rows.map(r => ({ ...r, analytics: analyticsOf(db, r.id) }));
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function analyticsOf(db, entryId) {
|
|
414
|
+
const out = { D: {}, C: {} };
|
|
415
|
+
for (const a of db.prepare('SELECT side, slot, ref_id, ref_cid FROM entry_analytics WHERE entry_id = ?').all(entryId))
|
|
416
|
+
out[a.side][a.slot] = { ref_id: a.ref_id, ref_cid: a.ref_cid };
|
|
417
|
+
return out;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Сторно действующих проводок: дата прежняя, а в закрытом периоде — now. */
|
|
421
|
+
function stornoEntries(db, op, version, author, now) {
|
|
422
|
+
const rows = effectiveRows(db, op.id).map(e => ({ ...e, time: isClosed(db, e.time) ? now : e.time }));
|
|
423
|
+
insertEntries(db, op, rows, { version, author, storno: true, now });
|
|
424
|
+
return rows.length;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Собрать id номенклатур (слот item) из списка проводок. */
|
|
428
|
+
function collectItemIds(entries) {
|
|
429
|
+
const ids = new Set();
|
|
430
|
+
for (const e of entries || []) {
|
|
431
|
+
for (const side of ['D', 'C']) {
|
|
432
|
+
const ref = e?.analytics?.[side]?.item?.ref_id;
|
|
433
|
+
if (ref)
|
|
434
|
+
ids.add(String(ref));
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return ids;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function schemaUsesUnitCost(schema) {
|
|
441
|
+
for (const r of schema?.postings || []) {
|
|
442
|
+
if (/\bunit_cost\s*\(/.test(`${r.amount ?? ''} ${r.qty ?? ''} ${r.qty2 ?? ''}`))
|
|
443
|
+
return true;
|
|
444
|
+
}
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function entriesEqual(curRows, nextEntries) {
|
|
449
|
+
if (curRows.length !== nextEntries.length)
|
|
450
|
+
return false;
|
|
451
|
+
for (let i = 0; i < curRows.length; i++) {
|
|
452
|
+
const a = curRows[i], b = nextEntries[i];
|
|
453
|
+
if (a.rule !== b.rule || a.row !== b.row || a.debit_cid !== b.debit_cid || a.credit_cid !== b.credit_cid)
|
|
454
|
+
return false;
|
|
455
|
+
if (a.amount !== b.amount || Math.abs((a.qty || 0) - (b.qty || 0)) > 1e-6 || Math.abs((a.qty2 || 0) - (b.qty2 || 0)) > 1e-6)
|
|
456
|
+
return false;
|
|
457
|
+
}
|
|
458
|
+
return true;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Пересчитать через сторно последующие зарегистрированные операции, зависящие от `unit_cost(...)`
|
|
463
|
+
* по затронутым номенклатурам, если приход/расход был проведён, исправлен или отменён задним числом.
|
|
464
|
+
*/
|
|
465
|
+
function repostDependentOperations(db, triggerOpId, minTime, itemIds, author, now) {
|
|
466
|
+
if (!itemIds || !itemIds.size || !Number.isFinite(minTime))
|
|
467
|
+
return 0;
|
|
468
|
+
const items = [...itemIds];
|
|
469
|
+
const marks = items.map(() => '?').join(',');
|
|
470
|
+
const rows = db.prepare(`SELECT DISTINCT o.id, o.time, o.created
|
|
471
|
+
FROM objects o
|
|
472
|
+
JOIN entries e ON e.op_id = o.id AND e.storno = 0
|
|
473
|
+
JOIN entry_analytics a ON a.entry_id = e.id AND a.slot = 'item' AND a.ref_id IN (${marks})
|
|
474
|
+
WHERE o.status = 'registered' AND o.id <> ? AND o.time >= ?
|
|
475
|
+
ORDER BY o.time ASC, o.created ASC, o.id ASC`).all(...items, String(triggerOpId), minTime);
|
|
476
|
+
if (!rows.length)
|
|
477
|
+
return 0;
|
|
478
|
+
const candidates = [];
|
|
479
|
+
for (const r of rows) {
|
|
480
|
+
if (isClosed(db, r.time))
|
|
481
|
+
continue;
|
|
482
|
+
const { op, schema } = mustOperation(db, r.id);
|
|
483
|
+
if (schemaUsesUnitCost(schema))
|
|
484
|
+
candidates.push({ op, schema });
|
|
485
|
+
}
|
|
486
|
+
if (!candidates.length)
|
|
487
|
+
return 0;
|
|
488
|
+
let reposted = 0;
|
|
489
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
490
|
+
const { op, schema } = candidates[i];
|
|
491
|
+
const excludeIds = candidates.slice(i).map(c => c.op.id);
|
|
492
|
+
const { entries: nextEntries, errors } = computeEntries(db, schema, op, { excludeIds });
|
|
493
|
+
if (errors.length)
|
|
494
|
+
continue;
|
|
495
|
+
const curRows = effectiveRows(db, op.id);
|
|
496
|
+
if (entriesEqual(curRows, nextEntries))
|
|
497
|
+
continue;
|
|
498
|
+
const depNext = writeVersion(db, op.id, { author: author ?? op.author, system: true, force: true });
|
|
499
|
+
stornoEntries(db, depNext, depNext.version, author ?? op.author, now);
|
|
500
|
+
insertEntries(db, depNext, nextEntries, { time: depNext.time, version: depNext.version, author: author ?? op.author, now });
|
|
501
|
+
reposted++;
|
|
502
|
+
}
|
|
503
|
+
return reposted;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/* ---------- жизненный цикл ---------- */
|
|
507
|
+
|
|
508
|
+
function mustOperation(db, id) {
|
|
509
|
+
const op = readObject(db, id);
|
|
510
|
+
if (!op)
|
|
511
|
+
throw new DataError(`операция ${id} не найдена`);
|
|
512
|
+
const schema = classSchema(db, op.cid);
|
|
513
|
+
if (schema.family !== 'operation')
|
|
514
|
+
throw new DataError(`«${op.title || id}» — не операция (${schema.label})`);
|
|
515
|
+
return { op, schema };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const opName = op => `«${op.title || op.id}»`;
|
|
519
|
+
|
|
520
|
+
function postOrFail(db, schema, op) {
|
|
521
|
+
const { entries, errors } = computeEntries(db, schema, op);
|
|
522
|
+
if (errors.length)
|
|
523
|
+
throw new DataError(`${opName(op)}: проводки не сформированы`, errors);
|
|
524
|
+
return entries;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Зарегистрировать черновик: проводки создаются в той же транзакции.
|
|
529
|
+
* Повторная регистрация зарегистрированной операции ничего не меняет.
|
|
530
|
+
* @param {object} p { id, author?, version? — ожидаемая версия }
|
|
531
|
+
*/
|
|
532
|
+
export function registerOperation(db, { id, author, version } = {}) {
|
|
533
|
+
const { op, schema } = mustOperation(db, id);
|
|
534
|
+
if (op.status === 'registered')
|
|
535
|
+
return op;
|
|
536
|
+
if (op.status !== 'draft' && op.status !== 'cancelled')
|
|
537
|
+
throw new DataError(`операция ${opName(op)} в статусе ${op.status} — регистрируется только черновик`);
|
|
538
|
+
if (isClosed(db, op.time))
|
|
539
|
+
throw closedError(db, `регистрировать операцию от ${localDay(op.time)} нельзя`);
|
|
540
|
+
assertRules(db, schema);
|
|
541
|
+
return tx(db, () => {
|
|
542
|
+
const now = Date.now();
|
|
543
|
+
const entries = postOrFail(db, schema, op);
|
|
544
|
+
const next = writeVersion(db, op.id, { status: 'registered', author, version, system: true });
|
|
545
|
+
insertEntries(db, next, entries, { time: next.time, version: next.version, author, now });
|
|
546
|
+
repostDependentOperations(db, next.id, next.time, collectItemIds(entries), author, now);
|
|
547
|
+
return next;
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Исправить зарегистрированную операцию: новая версия, сторно прежних проводок
|
|
553
|
+
* и новые проводки — одной транзакцией. Без изменений — ничего не происходит.
|
|
554
|
+
* @param {object} p { id, body — изменяемые поля (null — очистить), replace?, time?, author?, version? }
|
|
555
|
+
*/
|
|
556
|
+
export function correctOperation(db, p = {}) {
|
|
557
|
+
const { op, schema } = mustOperation(db, p.id);
|
|
558
|
+
if (op.status === 'draft')
|
|
559
|
+
throw new DataError(`операция ${opName(op)} — черновик: её правят обычным изменением`);
|
|
560
|
+
if (op.status !== 'registered')
|
|
561
|
+
throw new DataError(`операция ${opName(op)} в статусе ${op.status} — исправляется только зарегистрированная`);
|
|
562
|
+
const time = isEmpty(p.time) ? op.time : parseTime(p.time, 'дата операции');
|
|
563
|
+
if (time !== op.time && isClosed(db, time))
|
|
564
|
+
throw closedError(db, `перенести операцию на ${localDay(time)} нельзя`);
|
|
565
|
+
assertRules(db, schema);
|
|
566
|
+
return tx(db, () => {
|
|
567
|
+
const prevRows = effectiveRows(db, op.id);
|
|
568
|
+
const next = writeVersion(db, op.id, { body: mergeBody(op.body, p), time, author: p.author, version: p.version, system: true });
|
|
569
|
+
if (next.version === op.version)
|
|
570
|
+
return op;
|
|
571
|
+
const now = Date.now();
|
|
572
|
+
const entries = postOrFail(db, schema, next);
|
|
573
|
+
stornoEntries(db, next, next.version, p.author, now);
|
|
574
|
+
const entryTime = isClosed(db, next.time) ? now : next.time;
|
|
575
|
+
insertEntries(db, next, entries, { time: entryTime, version: next.version, author: p.author, now });
|
|
576
|
+
const affectedItems = new Set([...collectItemIds(prevRows), ...collectItemIds(entries)]);
|
|
577
|
+
const minTime = Math.min(op.time, entryTime);
|
|
578
|
+
repostDependentOperations(db, next.id, minTime, affectedItems, p.author, now);
|
|
579
|
+
return next;
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Отменить зарегистрированную операцию: сторно всех действующих проводок.
|
|
585
|
+
* @param {object} p { id, author?, version? }
|
|
586
|
+
*/
|
|
587
|
+
export function cancelOperation(db, { id, author, version } = {}) {
|
|
588
|
+
const { op } = mustOperation(db, id);
|
|
589
|
+
if (op.status === 'cancelled')
|
|
590
|
+
return op;
|
|
591
|
+
if (op.status !== 'registered')
|
|
592
|
+
throw new DataError(`операция ${opName(op)} в статусе ${op.status} — отменяется только зарегистрированная (черновик удаляют)`);
|
|
593
|
+
return tx(db, () => {
|
|
594
|
+
const now = Date.now();
|
|
595
|
+
const prevRows = effectiveRows(db, op.id);
|
|
596
|
+
const next = writeVersion(db, op.id, { status: 'cancelled', author, version, system: true });
|
|
597
|
+
stornoEntries(db, next, next.version, author, now);
|
|
598
|
+
if (!isClosed(db, op.time))
|
|
599
|
+
repostDependentOperations(db, next.id, op.time, collectItemIds(prevRows), author, now);
|
|
600
|
+
return next;
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Проводки, которые дала бы операция, без записи: существующая (`id`) или
|
|
606
|
+
* несохранённая (`cid`, `body`, `time`). Для формы и ИИ: показать результат до регистрации.
|
|
607
|
+
* @returns {{ entries: object[], errors: Array<{field, message}> }} amount — в рублях
|
|
608
|
+
*/
|
|
609
|
+
export function previewEntries(db, p = {}) {
|
|
610
|
+
let op, schema;
|
|
611
|
+
if (!isEmpty(p.id))
|
|
612
|
+
({ op, schema } = mustOperation(db, p.id));
|
|
613
|
+
else {
|
|
614
|
+
schema = classSchema(db, p.cid);
|
|
615
|
+
if (!schema || schema.family !== 'operation')
|
|
616
|
+
throw new DataError(`класс операции ${p.cid} не найден`);
|
|
617
|
+
const time = isEmpty(p.time) ? Date.now() : parseTime(p.time, 'дата операции');
|
|
618
|
+
op = { id: '', cid: schema.cid, time, body: validateBody(db, schema, p.body ?? {}) };
|
|
619
|
+
}
|
|
620
|
+
assertRules(db, schema);
|
|
621
|
+
const { entries, errors } = computeEntries(db, schema, op);
|
|
622
|
+
return { entries: entries.map(e => presentEntry({ ...e, time: op.time, storno: 0 })), errors };
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** Проводка для показа: amount — в рублях, аналитики сторон — { слот: id }. */
|
|
626
|
+
export function presentEntry(e) {
|
|
627
|
+
const side = a => Object.fromEntries(Object.entries(a).map(([slot, v]) => [slot, v.ref_id]));
|
|
628
|
+
return {
|
|
629
|
+
...(e.id !== undefined ? { id: e.id, op_id: e.op_id, op_version: e.op_version, op_cid: e.op_cid } : {}),
|
|
630
|
+
rule: e.rule,
|
|
631
|
+
row: e.row,
|
|
632
|
+
time: e.time,
|
|
633
|
+
debit: e.debit_cid,
|
|
634
|
+
credit: e.credit_cid,
|
|
635
|
+
amount: e.amount / 100,
|
|
636
|
+
qty: e.qty,
|
|
637
|
+
qty2: e.qty2,
|
|
638
|
+
doc_qty: e.doc_qty,
|
|
639
|
+
doc_unit: e.doc_unit,
|
|
640
|
+
debit_analytics: side(e.analytics.D),
|
|
641
|
+
credit_analytics: side(e.analytics.C),
|
|
642
|
+
...(e.id !== undefined ? { author: e.author, place: e.place, unit: e.unit } : {}),
|
|
643
|
+
storno: !!e.storno,
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Проводки операции (amount — в рублях).
|
|
649
|
+
* @param {object} [opts] { history — все проводки всех версий, включая сторно; иначе — действующие }
|
|
650
|
+
*/
|
|
651
|
+
export function operationEntries(db, id, { history = false } = {}) {
|
|
652
|
+
const { op } = mustOperation(db, id);
|
|
653
|
+
if (!history)
|
|
654
|
+
return op.status === 'registered' ? effectiveRows(db, op.id).map(presentEntry) : [];
|
|
655
|
+
return db.prepare('SELECT * FROM entries WHERE op_id = ? ORDER BY id').all(op.id)
|
|
656
|
+
.map(r => presentEntry({ ...r, analytics: analyticsOf(db, r.id) }));
|
|
657
|
+
}
|