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,524 @@
1
+ /**
2
+ * Печатные формы операций (docs/architecture-v2.md, Р11): HTML-шаблоны в метапапке класса,
3
+ * `<класс>/$operation/print/*.html`, наследуются вниз по дереву классов: шаблон «Платёжное
4
+ * поручение» у «Платежей с расчётного счёта» применим ко всем дочерним платежам, одноимённый
5
+ * шаблон ближайшего класса заменяет родительский.
6
+ *
7
+ * Язык шаблона — не JS, выражения того же мини-языка, что у правил проводок (expr.js):
8
+ * {{выражение}} значение, всегда экранируется; пустое — ничего;
9
+ * {{#таблица}}…{{/таблица}} строки табличной части: `row` — строка, `i` — номер с единицы;
10
+ * {{#if условие}}…{{/if}} блок по условию;
11
+ * {{+имя = выражение}} накопить сумму (внутри блока строк) — после блока доступна как `имя`.
12
+ * Поля-ссылки показываются названиями объектов на дату операции (id — в `ids`, у строк — `row.<поле>_id`).
13
+ * Доступно: поля тела, `doc` { number, date, status, label, id, unit, place },
14
+ * `org` { name, inn, kpp, ogrn, address, phone, email, bank, bik, account, corr }, функции money(x), date(x), words(x), n(x, знаков), norm(вид, объект?) — норматив на дату операции.
15
+ * Название формы — `<title>` шаблона, иначе имя файла.
16
+ */
17
+ import fsp from 'node:fs/promises';
18
+ import path from 'node:path';
19
+ import { getClass, ancestors, classesOfType } from './classes.js';
20
+ import { classSchema, findAncestor, classBasePath } from './schema.js';
21
+ import { readObject } from './objects.js';
22
+ import { normValue } from './posting.js';
23
+ import { compile, round, toNumber } from './expr.js';
24
+ import { DataError } from './errors.js';
25
+ import { localDay } from './time.js';
26
+
27
+ const EXT = '.html';
28
+ const STATUS = { draft: 'черновик', registered: 'проведена', cancelled: 'отменена', deleted: 'удалена' };
29
+
30
+ const isEmpty = v => v === undefined || v === null || v === '';
31
+ const num = v => toNumber(v, msg => { throw new DataError(msg); });
32
+
33
+ /* ---------- шаблоны класса ---------- */
34
+
35
+ const SEMANTIC_PRINT_ANCESTORS = {
36
+ 'Платёжное поручение': '/MODULES/Register/OPERATIONS/Платежи с расчётного счёта',
37
+ 'Списание со счёта': '/MODULES/Register/OPERATIONS/Платежи с расчётного счёта',
38
+ 'Банковский ордер': '/MODULES/Register/OPERATIONS/Платежи с расчётного счёта',
39
+ 'Инкассовые поручения': '/MODULES/Register/OPERATIONS/Платежи с расчётного счёта',
40
+ 'Счета': '/MODULES/Register/OPERATIONS/Реализация/Покупателю',
41
+ 'Расходные накладные': '/MODULES/Register/OPERATIONS/Реализация/Покупателю',
42
+ };
43
+
44
+ /** Файлы шаблонов класса по цепочке предков: имя → { name, file, source (путь класса), label }. */
45
+ export async function printTemplates(db, root, ref) {
46
+ const cls = getClass(db, ref);
47
+ if (!cls)
48
+ throw new DataError(`класс ${ref} не найден`);
49
+ const chain = ancestors(db, cls.cid);
50
+ const layers = [];
51
+ for (const c of chain) {
52
+ const anc = findAncestor(db, c);
53
+ if (anc) {
54
+ for (const ac of ancestors(db, anc.cid)) {
55
+ if (!layers.some(x => x.cid === ac.cid))
56
+ layers.push(ac);
57
+ }
58
+ }
59
+ const leafName = String(c.path || '').split('/').filter(Boolean).pop() || '';
60
+ const semPath = SEMANTIC_PRINT_ANCESTORS[leafName];
61
+ if (semPath) {
62
+ const semCls = getClass(db, semPath) || { cid: semPath, path: semPath, type: '$operation' };
63
+ if (!layers.some(x => x.path === semCls.path))
64
+ layers.push(semCls);
65
+ }
66
+ }
67
+ for (const c of chain) {
68
+ if (!layers.some(x => x.cid === c.cid))
69
+ layers.push(c);
70
+ }
71
+ const found = new Map();
72
+ for (const c of layers) {
73
+ const dir = path.join(root, ...c.path.split('/').filter(Boolean), c.type, 'print');
74
+ let names = [];
75
+ try {
76
+ names = (await fsp.readdir(dir)).filter(n => n.toLowerCase().endsWith(EXT));
77
+ }
78
+ catch (e) {
79
+ if (e.code !== 'ENOENT' && e.code !== 'ENOTDIR')
80
+ throw e;
81
+ }
82
+ for (const file of names.sort()) {
83
+ const name = file.slice(0, -EXT.length);
84
+ found.set(name, { name, file: path.join(dir, file), source: c.path });
85
+ }
86
+ }
87
+ const out = [];
88
+ for (const t of found.values()) {
89
+ const text = await fsp.readFile(t.file, 'utf-8');
90
+ out.push({ ...t, label: /<title>([\s\S]*?)<\/title>/i.exec(text)?.[1].trim() || t.name });
91
+ }
92
+ return out.sort((a, b) => a.label.localeCompare(b.label, 'ru'));
93
+ }
94
+
95
+ /* ---------- разбор шаблона ---------- */
96
+
97
+ const TAG = /\{\{\s*([\s\S]+?)\s*\}\}/g;
98
+
99
+ /** Шаблон → дерево блоков (ошибки разметки — DataError с названием формы). */
100
+ export function parseTemplate(text, what = 'шаблон') {
101
+ const root = { children: [] };
102
+ const stack = [root];
103
+ let last = 0;
104
+ const add = n => stack.at(-1).children.push(n);
105
+ const open = n => {
106
+ add(n);
107
+ stack.push(n);
108
+ };
109
+ for (const m of text.matchAll(TAG)) {
110
+ if (m.index > last)
111
+ add({ t: 'text', v: text.slice(last, m.index) });
112
+ last = m.index + m[0].length;
113
+ const src = m[1];
114
+ let r;
115
+ if ((r = /^#if\s+([\s\S]+)$/.exec(src)))
116
+ open({ t: 'if', cond: compileOrFail(r[1], what), children: [] });
117
+ else if ((r = /^#([\p{L}_$][\p{L}\p{N}_$]*)$/u.exec(src)))
118
+ open({ t: 'each', name: r[1], children: [] });
119
+ else if ((r = /^\/(\S*)$/.exec(src))) {
120
+ const top = stack.at(-1);
121
+ const want = top.t === 'if' ? 'if' : top.name;
122
+ if (stack.length === 1 || r[1] !== want)
123
+ throw new DataError(`${what}: {{/${r[1]}}} без открывающего блока${stack.length > 1 ? ` (ожидалось {{/${want}}})` : ''}`);
124
+ stack.pop();
125
+ }
126
+ else if ((r = /^\+\s*([\p{L}_$][\p{L}\p{N}_$]*)\s*=\s*([\s\S]+)$/u.exec(src)))
127
+ add({ t: 'acc', name: r[1], expr: compileOrFail(r[2], what) });
128
+ else
129
+ add({ t: 'expr', expr: compileOrFail(src, what) });
130
+ }
131
+ if (last < text.length)
132
+ add({ t: 'text', v: text.slice(last) });
133
+ if (stack.length > 1)
134
+ throw new DataError(`${what}: не закрыт блок {{#${stack.at(-1).name ?? 'if'}}}`);
135
+ return root.children;
136
+ }
137
+
138
+ function compileOrFail(src, what) {
139
+ try {
140
+ return compile(src);
141
+ }
142
+ catch (e) {
143
+ throw new DataError(`${what}: ${e.message}`);
144
+ }
145
+ }
146
+
147
+ /* ---------- форматирование ---------- */
148
+
149
+ const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
150
+
151
+ /** Число с разрядами и запятой: 1 234,50 (разряды — неразрывным пробелом, чтобы сумма не переносилась). */
152
+ function fmtNumber(x, digits) {
153
+ return num(x).toLocaleString('ru-RU', { minimumFractionDigits: digits, maximumFractionDigits: digits }).replace(/\s/g, '\u00a0');
154
+ }
155
+
156
+ /** Дата 'ДД.ММ.ГГГГ' из момента, дня 'YYYY-MM-DD' или пустого значения. */
157
+ function fmtDate(v) {
158
+ if (isEmpty(v))
159
+ return '';
160
+ const day = typeof v === 'number' ? localDay(v) : String(v).slice(0, 10);
161
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day);
162
+ return m ? `${m[3]}.${m[2]}.${m[1]}` : String(v);
163
+ }
164
+
165
+ const ONES = [['', ''], ['один', 'одна'], ['два', 'две'], ['три', 'три'], ['четыре', 'четыре'], ['пять', 'пять'], ['шесть', 'шесть'],
166
+ ['семь', 'семь'], ['восемь', 'восемь'], ['девять', 'девять']];
167
+ const TEENS = ['десять', 'одиннадцать', 'двенадцать', 'тринадцать', 'четырнадцать', 'пятнадцать', 'шестнадцать', 'семнадцать', 'восемнадцать', 'девятнадцать'];
168
+ const TENS = ['', '', 'двадцать', 'тридцать', 'сорок', 'пятьдесят', 'шестьдесят', 'семьдесят', 'восемьдесят', 'девяносто'];
169
+ const HUNDREDS = ['', 'сто', 'двести', 'триста', 'четыреста', 'пятьсот', 'шестьсот', 'семьсот', 'восемьсот', 'девятьсот'];
170
+ const SCALES = [['', '', '', 0], ['тысяча', 'тысячи', 'тысяч', 1], ['миллион', 'миллиона', 'миллионов', 0], ['миллиард', 'миллиарда', 'миллиардов', 0]];
171
+
172
+ function plural(n, forms) {
173
+ const a = n % 100, b = n % 10;
174
+ if (a > 10 && a < 20)
175
+ return forms[2];
176
+ return b === 1 ? forms[0] : b >= 2 && b <= 4 ? forms[1] : forms[2];
177
+ }
178
+
179
+ function triad(n, fem) {
180
+ const out = [HUNDREDS[Math.floor(n / 100)]];
181
+ const rest = n % 100;
182
+ if (rest >= 10 && rest < 20)
183
+ out.push(TEENS[rest - 10]);
184
+ else
185
+ out.push(TENS[Math.floor(rest / 10)], ONES[rest % 10][fem ? 1 : 0]);
186
+ return out.filter(Boolean).join(' ');
187
+ }
188
+
189
+ function integerWords(n, fem = false) {
190
+ if (n === 0)
191
+ return 'ноль';
192
+ const parts = [];
193
+ for (let i = 0, rest = n; rest > 0 && i < SCALES.length; i++, rest = Math.floor(rest / 1000)) {
194
+ const t = rest % 1000;
195
+ if (!t)
196
+ continue;
197
+ const [one, two, five, feminine] = SCALES[i];
198
+ parts.unshift([triad(t, i === 0 ? fem : !!feminine), i ? plural(t, [one, two, five]) : ''].filter(Boolean).join(' '));
199
+ }
200
+ return parts.join(' ');
201
+ }
202
+
203
+ /** Сумма прописью: «Сто двадцать три рубля 45 копеек» (с заглавной буквы). */
204
+ export function amountInWords(v) {
205
+ const total = Math.round(Math.abs(num(v)) * 100);
206
+ const rub = Math.floor(total / 100), kop = total % 100;
207
+ const text = `${integerWords(rub)} ${plural(rub, ['рубль', 'рубля', 'рублей'])} ${String(kop).padStart(2, '0')} ${plural(kop, ['копейка', 'копейки', 'копеек'])}`;
208
+ return (num(v) < 0 ? 'минус ' : '') + text.charAt(0).toUpperCase() + text.slice(1);
209
+ }
210
+
211
+ const FUNCTIONS = Object.freeze({
212
+ money: x => fmtNumber(x, 2),
213
+ n: (x, digits = 0) => fmtNumber(x, Math.max(0, Math.min(6, Math.trunc(num(digits))))),
214
+ date: fmtDate,
215
+ words: amountInWords,
216
+ });
217
+
218
+ /* ---------- данные документа ---------- */
219
+
220
+ /** Название объекта на дату (состояние на момент операции, иначе текущее). */
221
+ function titleOf(db, cache, id, at) {
222
+ if (isEmpty(id))
223
+ return '';
224
+ const key = String(id);
225
+ if (!cache.has(key)) {
226
+ const o = readObject(db, key, { at }) ?? readObject(db, key);
227
+ cache.set(key, o?.title || key);
228
+ }
229
+ return cache.get(key);
230
+ }
231
+
232
+ /** Тело для показа: ссылки — названиями, id ссылок — в `ids` (у строк — `<поле>_id`), полные объекты ссылок — в `<поле>_obj`. */
233
+ function displayBody(db, fields, body, at, cache) {
234
+ const out = { ...body };
235
+ const ids = {};
236
+ const objs = {};
237
+ for (const f of fields) {
238
+ if (f.type === 'link' && !isEmpty(body[f.id])) {
239
+ ids[f.id] = body[f.id];
240
+ out[f.id] = titleOf(db, cache, body[f.id], at);
241
+ const refObj = readObject(db, body[f.id], { at }) ?? readObject(db, body[f.id]);
242
+ if (refObj) {
243
+ const full = { id: refObj.id, title: refObj.title || '', name: refObj.body?.name || refObj.title || '', ...(refObj.body || {}) };
244
+ out[`${f.id}_obj`] = full;
245
+ objs[f.id] = full;
246
+ }
247
+ }
248
+ else if (f.type === 'table' && Array.isArray(body[f.id])) {
249
+ out[f.id] = body[f.id].map(row => {
250
+ const r = { ...row };
251
+ for (const c of f.fields) {
252
+ if (c.type === 'link' && !isEmpty(row[c.id])) {
253
+ r[`${c.id}_id`] = row[c.id];
254
+ r[c.id] = titleOf(db, cache, row[c.id], at);
255
+ }
256
+ }
257
+ return r;
258
+ });
259
+ }
260
+ }
261
+ return { out, ids, objs };
262
+ }
263
+
264
+ /** Извлечь реквизиты контрагента/получателя/плательщика для унифицированных печатных бланков (`party.*`). */
265
+ function partyOf(body, out, objs) {
266
+ const partyKeys = ['recipient', 'counterparty', 'payer', 'client', 'supplier', 'customer', 'employee', 'person'];
267
+ let ref = null;
268
+ for (const k of partyKeys) {
269
+ if (objs?.[k]) {
270
+ ref = objs[k];
271
+ break;
272
+ }
273
+ }
274
+ const firstAcc = Array.isArray(ref?.accounts) && ref.accounts.length ? ref.accounts[0] : null;
275
+ const rawReq = String(body?.acct_r || '').trim();
276
+ const accMatch = rawReq.match(/\b(40\d{18}|301\d{17})\b/);
277
+ const bikMatch = rawReq.match(/\b(04\d{7})\b/);
278
+ const innMatch = rawReq.match(/ИНН\s*(\d{10,12})/i);
279
+ const kppMatch = rawReq.match(/КПП\s*(\d{9})/i);
280
+ return {
281
+ name: String(ref?.full_name || body?.recipient_name || body?.payer_name || body?.counterparty_name || ref?.title || ref?.name || out?.recipient || out?.counterparty || out?.payer || out?.client || out?.employee || ''),
282
+ inn: String(ref?.inn || innMatch?.[1] || ''),
283
+ kpp: String(ref?.kpp || kppMatch?.[1] || ''),
284
+ ogrn: String(ref?.ogrn || ''),
285
+ bank: String(ref?.bank || firstAcc?.bank || ''),
286
+ bik: String(ref?.bik || firstAcc?.bik || bikMatch?.[1] || ''),
287
+ account: String(ref?.account || firstAcc?.account || firstAcc?.num || accMatch?.[1] || (!bikMatch && !innMatch ? rawReq : '') || ''),
288
+ corr: String(ref?.corr || firstAcc?.corr || ''),
289
+ address: String(ref?.address || ''),
290
+ };
291
+ }
292
+
293
+ /** Реквизиты организации: название и значения из class.js корня `$base` (ИНН, КПП, адрес, банк …). */
294
+ const ORG_FIELDS = ['full_name', 'inn', 'kpp', 'ogrn', 'okpo', 'okato', 'address', 'phone', 'email', 'director', 'chief_accountant', 'bank', 'bik', 'account', 'corr'];
295
+
296
+ function orgOf(db, op = null) {
297
+ const bp = (op?.cid && classBasePath(db, op.cid)) || (op?.unit && classBasePath(db, op.unit)) || '';
298
+ const root = (bp && getClass(db, bp)) ?? getClass(db, 'org') ?? classesOfType(db, '$base')[0];
299
+ const own = root?.own ?? {};
300
+ const org = { name: root?.label ?? '' };
301
+ for (const k of ORG_FIELDS)
302
+ org[k] = own[k] == null ? '' : String(own[k]);
303
+ return org;
304
+ }
305
+
306
+ /* ---------- вывод ---------- */
307
+
308
+ function show(v) {
309
+ if (isEmpty(v))
310
+ return '';
311
+ if (Array.isArray(v))
312
+ return v.map(show).join(', ');
313
+ if (typeof v === 'object')
314
+ return String(v.title ?? v.name ?? v.id ?? '');
315
+ if (typeof v === 'boolean')
316
+ return v ? 'да' : 'нет';
317
+ if (typeof v === 'number')
318
+ return String(round(v, 6));
319
+ return String(v);
320
+ }
321
+
322
+ function render(nodes, base, vars, what, fns) {
323
+ let out = '';
324
+ const scope = extra => ({ ...base, ...vars, ...extra });
325
+ const run = (expr, extra) => {
326
+ try {
327
+ return expr.run(scope(extra), fns);
328
+ }
329
+ catch (e) {
330
+ if (e instanceof DataError)
331
+ throw e;
332
+ throw new DataError(`${what}: ${e.message}`);
333
+ }
334
+ };
335
+ const walk = (list, extra) => {
336
+ for (const n of list) {
337
+ switch (n.t) {
338
+ case 'text':
339
+ out += n.v;
340
+ break;
341
+ case 'expr':
342
+ out += esc(show(run(n.expr, extra)));
343
+ break;
344
+ case 'acc':
345
+ vars[n.name] = round((Number(vars[n.name]) || 0) + num(run(n.expr, extra)), 6);
346
+ break;
347
+ case 'if':
348
+ if (run(n.cond, extra))
349
+ walk(n.children, extra);
350
+ break;
351
+ case 'each': {
352
+ const rows = scope(extra)[n.name];
353
+ if (!Array.isArray(rows))
354
+ throw new DataError(`${what}: «${n.name}» — не табличная часть`);
355
+ rows.forEach((row, i) => walk(n.children, { ...extra, row, i: i + 1 }));
356
+ break;
357
+ }
358
+ }
359
+ }
360
+ };
361
+ walk(nodes, {});
362
+ return out;
363
+ }
364
+
365
+ function buildStarterTemplateForSchema(schema) {
366
+ const scalarRows = (schema.fields || [])
367
+ .filter(f => f.type !== 'table' && f.type !== 'struct')
368
+ .map(f => {
369
+ const valExpr = f.type === 'money' ? `{{money(${f.id})}} ₽` : f.type === 'date' ? `{{date(${f.id})}}` : `{{${f.id}}}`;
370
+ return ` {{#if ${f.id}}}<tr><td class="k">${esc(f.label || f.id)}</td><td>${valExpr}</td></tr>{{/if}}`;
371
+ })
372
+ .join('\n');
373
+
374
+ const tableBlocks = (schema.fields || [])
375
+ .filter(f => f.type === 'table' && Array.isArray(f.fields) && f.fields.length)
376
+ .map(f => {
377
+ const ths = ['<th>№</th>', ...f.fields.map(c => `<th>${esc(c.label || c.id)}</th>`)].join('');
378
+ const tds = ['<td class="c">{{i}}</td>', ...f.fields.map(c => c.type === 'money' ? `<td class="r">{{money(row.${c.id})}}</td>` : `<td${c.type === 'number' || c.type === 'integer' ? ' class="r"' : ''}>{{row.${c.id}}}</td>`)].join('');
379
+ return `
380
+ <h3>${esc(f.label || f.id)}</h3>
381
+ <table>
382
+ <thead><tr>${ths}</tr></thead>
383
+ <tbody>
384
+ {{#${f.id}}}
385
+ <tr>${tds}</tr>
386
+ {{/${f.id}}}
387
+ </tbody>
388
+ </table>`;
389
+ })
390
+ .join('\n');
391
+
392
+ return `<!DOCTYPE html>
393
+ <html lang="ru">
394
+ <head>
395
+ <meta charset="utf-8">
396
+ <title>${esc(schema.label || 'Документ')}</title>
397
+ <style>
398
+ @page { size: A4 portrait; margin: 15mm; }
399
+ body { font: 13px/1.4 Arial, sans-serif; margin: 0; padding: 15mm; color: #000; background: #fff; }
400
+ @media print { body { padding: 0; } }
401
+ .org-line { font-size: 12px; color: #333; border-bottom: 2px solid #000; padding-bottom: 6px; margin-bottom: 14px; }
402
+ h1 { font-size: 18px; margin: 0 0 4px; }
403
+ h3 { font-size: 14px; margin: 14px 0 6px; }
404
+ .sub { color: #444; margin-bottom: 14px; font-size: 12.5px; }
405
+ table { border-collapse: collapse; width: 100%; margin-bottom: 12px; }
406
+ th, td { border: 1px solid #000; padding: 5px 8px; vertical-align: top; text-align: left; }
407
+ th { background: #f3f4f6; font-weight: bold; }
408
+ td.k { width: 32%; background: #f9fafb; font-weight: 600; }
409
+ td.r { text-align: right; }
410
+ td.c { text-align: center; width: 36px; }
411
+ .sign { margin-top: 32px; display: flex; gap: 48px; }
412
+ .sign div { border-top: 1px solid #000; min-width: 180px; padding-top: 4px; font-size: 11px; }
413
+ </style>
414
+ </head>
415
+ <body>
416
+ <div class="org-line"><b>{{coalesce(org.full_name, org.name)}}</b>{{#if org.inn}} · ИНН {{org.inn}}{{/if}}{{#if org.kpp}} · КПП {{org.kpp}}{{/if}}</div>
417
+ <h1>{{doc.label}} № {{doc.number}}</h1>
418
+ <div class="sub">Дата: <b>{{doc.date}}</b> · Статус: {{doc.status}}</div>
419
+ <table>
420
+ ${scalarRows}
421
+ </table>${tableBlocks}
422
+ <div class="sign">
423
+ <div>Руководитель {{org.director}}</div>
424
+ <div>Главный бухгалтер {{org.chief_accountant}}</div>
425
+ </div>
426
+ </body>
427
+ </html>`;
428
+ }
429
+
430
+ /**
431
+ * Печатная форма объекта.
432
+ * @param {object} p { id, name — имя шаблона, root — корень WORK на диске, fallback? — автошаблон при отсутствии файла, customHtml? — предпросмотр произвольного шаблона }
433
+ * @returns {Promise<{ name, label, html, source, templateHtml }>}
434
+ */
435
+ export async function renderPrint(db, { id, name, root, fallback = false, customHtml = '' }) {
436
+ const o = readObject(db, id);
437
+ if (!o)
438
+ throw new DataError(`объект ${id} не найден`);
439
+ const schema = classSchema(db, o.cid);
440
+ if (schema.family !== 'operation')
441
+ throw new DataError(`«${schema.label}» — печатные формы есть только у операций`);
442
+ const list = await printTemplates(db, root, o.cid);
443
+ let t = isEmpty(name) ? (list.length === 1 ? list[0] : (fallback && list.length ? list[0] : null)) : list.find(x => x.name === String(name));
444
+ let templateHtml = '';
445
+ if (typeof customHtml === 'string' && customHtml.trim()) {
446
+ templateHtml = customHtml;
447
+ if (!t)
448
+ t = { name: String(name || schema.label || 'Документ'), label: String(name || schema.label || 'Документ'), source: schema.path };
449
+ }
450
+ else if (t) {
451
+ templateHtml = await fsp.readFile(t.file, 'utf-8');
452
+ }
453
+ else if (fallback || name === '__default__') {
454
+ const defName = schema.label || 'Документ';
455
+ templateHtml = buildStarterTemplateForSchema(schema);
456
+ t = { name: defName, label: defName, source: schema.path, isDefault: true };
457
+ }
458
+ if (!t)
459
+ throw new DataError(isEmpty(name) && list.length
460
+ ? `у «${schema.label}» несколько форм — укажите name (${list.map(x => x.name).join(', ')})`
461
+ : `печатная форма «${name ?? ''}» у «${schema.label}» не найдена`);
462
+ const what = `форма «${t.label}»`;
463
+ const nodes = parseTemplate(templateHtml, what);
464
+ const cache = new Map();
465
+ const { out, ids, objs } = displayBody(db, schema.fields, o.body, o.time, cache);
466
+ const base = {
467
+ ...out,
468
+ ids,
469
+ party: partyOf(o.body, out, objs),
470
+ doc: {
471
+ id: o.id,
472
+ number: o.body.num || o.body.doc_number || o.body.order_number || o.body.number || o.id.slice(-6),
473
+ date: fmtDate(o.time),
474
+ status: STATUS[o.status] || o.status,
475
+ label: schema.label,
476
+ unit: o.unit ? getClass(db, o.unit)?.label || '' : '',
477
+ place: o.place || '',
478
+ },
479
+ org: orgOf(db, o),
480
+ };
481
+ const html = render(nodes, base, {}, what, { ...FUNCTIONS, norm: (kind, object) => normValue(db, kind, object, o.time) });
482
+ return { name: t.name, label: t.label, source: t.source, isDefault: Boolean(t.isDefault), templateHtml, html };
483
+ }
484
+
485
+ /** Сохранить (или обновить) HTML-шаблон печатной формы в `<класс>/$operation/print/<name>.html`. */
486
+ export async function savePrintTemplate(db, { ref, name, html, root }) {
487
+ const cls = getClass(db, ref);
488
+ if (!cls)
489
+ throw new DataError(`класс ${ref} не найден`);
490
+ if (cls.type !== '$operation')
491
+ throw new DataError(`«${cls.label}» — шаблоны печати поддерживаются только для классов операций ($operation)`);
492
+ const safeName = String(name || cls.label || 'Документ').trim().replace(/[\\/:*?"<>|]/g, '_');
493
+ if (!safeName)
494
+ throw new DataError('укажите название печатной формы');
495
+ const content = String(html ?? '').trim();
496
+ if (!content)
497
+ throw new DataError('текст шаблона печатной формы пуст');
498
+ // Проверим синтаксис шаблона перед сохранением
499
+ parseTemplate(content, `форма «${safeName}»`);
500
+ const dir = path.join(root, ...cls.path.split('/').filter(Boolean), cls.type, 'print');
501
+ await fsp.mkdir(dir, { recursive: true });
502
+ const file = path.join(dir, safeName + EXT);
503
+ await fsp.writeFile(file, content, 'utf-8');
504
+ return { ok: true, name: safeName, file, source: cls.path };
505
+ }
506
+
507
+ /** Удалить собственный HTML-шаблон печатной формы из `<класс>/$operation/print/<name>.html`. */
508
+ export async function deletePrintTemplate(db, { ref, name, root }) {
509
+ const cls = getClass(db, ref);
510
+ if (!cls)
511
+ throw new DataError(`класс ${ref} не найден`);
512
+ const safeName = String(name ?? '').trim().replace(/[\\/:*?"<>|]/g, '_');
513
+ if (!safeName)
514
+ throw new DataError('укажите название печатной формы');
515
+ const file = path.join(root, ...cls.path.split('/').filter(Boolean), cls.type, 'print', safeName + EXT);
516
+ try {
517
+ await fsp.unlink(file);
518
+ }
519
+ catch (e) {
520
+ if (e.code !== 'ENOENT')
521
+ throw e;
522
+ }
523
+ return { ok: true, name: safeName };
524
+ }