odant-work 1.0.1 → 1.0.2

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.
@@ -51,6 +51,74 @@ function _derivePacketsKey(from, to, fallback = '*') {
51
51
  return fallback || '*';
52
52
  }
53
53
 
54
+ function _daysInMonth(y, m) {
55
+ return new Date(y, m, 0).getDate();
56
+ }
57
+
58
+ function _weekBounds(dt) {
59
+ const dow = dt.getDay();
60
+ const diffToMon = dow === 0 ? -6 : 1 - dow;
61
+ const mon = new Date(dt.getFullYear(), dt.getMonth(), dt.getDate() + diffToMon);
62
+ const sun = new Date(mon.getFullYear(), mon.getMonth(), mon.getDate() + 6);
63
+ return { from: _day(mon), to: _day(sun) };
64
+ }
65
+
66
+ function _boundsFromPacketsKey(key, defaultMode = 'month') {
67
+ const k = String(key || '').trim();
68
+ if (!k || k === '*')
69
+ return { from: '', to: '' };
70
+ if (k.startsWith('period:')) {
71
+ const [, s = '', e = ''] = k.split(':');
72
+ return { from: s, to: e };
73
+ }
74
+ if (k === 'now' || k.startsWith('now:')) {
75
+ const gran = k.startsWith('now:') ? k.slice(4) : defaultMode;
76
+ const now = new Date();
77
+ const y = now.getFullYear(), m = now.getMonth() + 1;
78
+ if (gran === 'day') {
79
+ const d = _day(now);
80
+ return { from: d, to: d };
81
+ }
82
+ if (gran === 'week')
83
+ return _weekBounds(now);
84
+ if (gran === 'quarter') {
85
+ const q = Math.floor((m - 1) / 3) + 1;
86
+ const m1 = String((q - 1) * 3 + 1).padStart(2, '0');
87
+ const m2 = (q - 1) * 3 + 3;
88
+ return { from: `${y}-${m1}-01`, to: `${y}-${String(m2).padStart(2, '0')}-${String(_daysInMonth(y, m2)).padStart(2, '0')}` };
89
+ }
90
+ if (gran === 'year')
91
+ return { from: `${y}-01-01`, to: `${y}-12-31` };
92
+ const ms = String(m).padStart(2, '0');
93
+ return { from: `${y}-${ms}-01`, to: `${y}-${ms}-${String(_daysInMonth(y, m)).padStart(2, '0')}` };
94
+ }
95
+ const clean = k.replace(/\*+$/, '');
96
+ if (/^\d{4}$/.test(clean))
97
+ return { from: `${clean}-01-01`, to: `${clean}-12-31` };
98
+ const qm = /^(\d{4})-q([1-4])$/i.exec(clean);
99
+ if (qm) {
100
+ const y = +qm[1], q = +qm[2];
101
+ const m1 = String((q - 1) * 3 + 1).padStart(2, '0');
102
+ const m2 = (q - 1) * 3 + 3;
103
+ return { from: `${y}-${m1}-01`, to: `${y}-${String(m2).padStart(2, '0')}-${String(_daysInMonth(y, m2)).padStart(2, '0')}` };
104
+ }
105
+ const mm = /^(\d{4})-(0[1-9]|1[0-2])$/.exec(clean);
106
+ if (mm) {
107
+ const y = +mm[1], m = +mm[2];
108
+ return { from: `${clean}-01`, to: `${clean}-${String(_daysInMonth(y, m)).padStart(2, '0')}` };
109
+ }
110
+ if (clean.startsWith('week:')) {
111
+ const monIso = clean.slice(5);
112
+ if (/^\d{4}-\d{2}-\d{2}$/.test(monIso)) {
113
+ const [y, m, d] = monIso.split('-').map(Number);
114
+ return _weekBounds(new Date(y, m - 1, d));
115
+ }
116
+ }
117
+ if (/^\d{4}-\d{2}-\d{2}$/.test(clean))
118
+ return { from: clean, to: clean };
119
+ return { from: '', to: '' };
120
+ }
121
+
54
122
  const money = v => (Number(v) ? Number(v).toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '');
55
123
 
56
124
  export default {
@@ -449,14 +517,23 @@ export default {
449
517
  if (!hasNavPeriod && (this.from || this.to))
450
518
  return;
451
519
  const saved = _readSavedPeriod();
452
- const src = hasNavPeriod ? nav : saved;
520
+ const pk = typeof this.packetsKey === 'string' ? this.packetsKey : '';
521
+ const src = hasNavPeriod ? nav : (saved || (pk && pk !== '*' ? { packetsKey: pk } : null));
453
522
  if (!src)
454
523
  return;
455
524
  this._initializingPackets = true;
456
525
  try {
457
- const from = String(src.from || '');
458
- const to = String(src.to || '');
459
- const key = _derivePacketsKey(from, to, typeof src.packetsKey === 'string' ? src.packetsKey : '*');
526
+ const rawKey = typeof src.packetsKey === 'string' && src.packetsKey ? src.packetsKey : (pk || '*');
527
+ let from = String(src.from || '');
528
+ let to = String(src.to || '');
529
+ if ((!from && !to && rawKey !== '*') || rawKey === 'now' || rawKey.startsWith('now:')) {
530
+ const derived = _boundsFromPacketsKey(rawKey, 'month');
531
+ if (derived.from && derived.to) {
532
+ from = derived.from;
533
+ to = derived.to;
534
+ }
535
+ }
536
+ const key = _derivePacketsKey(from, to, rawKey);
460
537
  this.from = from;
461
538
  this.to = to;
462
539
  this._packetFiltered = !!(from || to);
@@ -526,7 +603,9 @@ export default {
526
603
  this.packetsStart = r.start;
527
604
  this.packetsEnd = r.end;
528
605
  this.packetDays = Array.isArray(r.days) && r.days.length ? r.days : null;
529
- if (this._packetFiltered && ((this.to && this.to < r.start) || (this.from && this.from > r.end))) {
606
+ const keyStr = typeof this.packetsKey === 'string' ? this.packetsKey : '';
607
+ const isNowKey = keyStr === 'now' || keyStr.startsWith('now:');
608
+ if (this._packetFiltered && !isNowKey && this.to && this.to < r.start) {
530
609
  this._initializingPackets = true;
531
610
  try {
532
611
  this._packetFiltered = false;
@@ -53,6 +53,74 @@ function _derivePacketsKey(from, to, fallback = '*') {
53
53
  return fallback || '*';
54
54
  }
55
55
 
56
+ function _daysInMonth(y, m) {
57
+ return new Date(y, m, 0).getDate();
58
+ }
59
+
60
+ function _weekBounds(dt) {
61
+ const dow = dt.getDay();
62
+ const diffToMon = dow === 0 ? -6 : 1 - dow;
63
+ const mon = new Date(dt.getFullYear(), dt.getMonth(), dt.getDate() + diffToMon);
64
+ const sun = new Date(mon.getFullYear(), mon.getMonth(), mon.getDate() + 6);
65
+ return { from: _day(mon), to: _day(sun) };
66
+ }
67
+
68
+ function _boundsFromPacketsKey(key, defaultMode = 'month') {
69
+ const k = String(key || '').trim();
70
+ if (!k || k === '*')
71
+ return { from: '', to: '' };
72
+ if (k.startsWith('period:')) {
73
+ const [, s = '', e = ''] = k.split(':');
74
+ return { from: s, to: e };
75
+ }
76
+ if (k === 'now' || k.startsWith('now:')) {
77
+ const gran = k.startsWith('now:') ? k.slice(4) : defaultMode;
78
+ const now = new Date();
79
+ const y = now.getFullYear(), m = now.getMonth() + 1;
80
+ if (gran === 'day') {
81
+ const d = _day(now);
82
+ return { from: d, to: d };
83
+ }
84
+ if (gran === 'week')
85
+ return _weekBounds(now);
86
+ if (gran === 'quarter') {
87
+ const q = Math.floor((m - 1) / 3) + 1;
88
+ const m1 = String((q - 1) * 3 + 1).padStart(2, '0');
89
+ const m2 = (q - 1) * 3 + 3;
90
+ return { from: `${y}-${m1}-01`, to: `${y}-${String(m2).padStart(2, '0')}-${String(_daysInMonth(y, m2)).padStart(2, '0')}` };
91
+ }
92
+ if (gran === 'year')
93
+ return { from: `${y}-01-01`, to: `${y}-12-31` };
94
+ const ms = String(m).padStart(2, '0');
95
+ return { from: `${y}-${ms}-01`, to: `${y}-${ms}-${String(_daysInMonth(y, m)).padStart(2, '0')}` };
96
+ }
97
+ const clean = k.replace(/\*+$/, '');
98
+ if (/^\d{4}$/.test(clean))
99
+ return { from: `${clean}-01-01`, to: `${clean}-12-31` };
100
+ const qm = /^(\d{4})-q([1-4])$/i.exec(clean);
101
+ if (qm) {
102
+ const y = +qm[1], q = +qm[2];
103
+ const m1 = String((q - 1) * 3 + 1).padStart(2, '0');
104
+ const m2 = (q - 1) * 3 + 3;
105
+ return { from: `${y}-${m1}-01`, to: `${y}-${String(m2).padStart(2, '0')}-${String(_daysInMonth(y, m2)).padStart(2, '0')}` };
106
+ }
107
+ const mm = /^(\d{4})-(0[1-9]|1[0-2])$/.exec(clean);
108
+ if (mm) {
109
+ const y = +mm[1], m = +mm[2];
110
+ return { from: `${clean}-01`, to: `${clean}-${String(_daysInMonth(y, m)).padStart(2, '0')}` };
111
+ }
112
+ if (clean.startsWith('week:')) {
113
+ const monIso = clean.slice(5);
114
+ if (/^\d{4}-\d{2}-\d{2}$/.test(monIso)) {
115
+ const [y, m, d] = monIso.split('-').map(Number);
116
+ return _weekBounds(new Date(y, m - 1, d));
117
+ }
118
+ }
119
+ if (/^\d{4}-\d{2}-\d{2}$/.test(clean))
120
+ return { from: clean, to: clean };
121
+ return { from: '', to: '' };
122
+ }
123
+
56
124
  export default {
57
125
  imports: 'oda//button, oda//packets',
58
126
  template: /*html*/`
@@ -489,14 +557,23 @@ export default {
489
557
  if (!hasNavPeriod && (this.from || this.to))
490
558
  return;
491
559
  const saved = _readSavedPeriod();
492
- const src = hasNavPeriod ? nav : saved;
560
+ const pk = typeof this.packetsKey === 'string' ? this.packetsKey : '';
561
+ const src = hasNavPeriod ? nav : (saved || (pk && pk !== '*' ? { packetsKey: pk } : null));
493
562
  if (!src)
494
563
  return;
495
564
  this._initializingPackets = true;
496
565
  try {
497
- const from = String(src.from || '');
498
- const to = String(src.to || '');
499
- const key = _derivePacketsKey(from, to, typeof src.packetsKey === 'string' ? src.packetsKey : '*');
566
+ const rawKey = typeof src.packetsKey === 'string' && src.packetsKey ? src.packetsKey : (pk || '*');
567
+ let from = String(src.from || '');
568
+ let to = String(src.to || '');
569
+ if ((!from && !to && rawKey !== '*') || rawKey === 'now' || rawKey.startsWith('now:')) {
570
+ const derived = _boundsFromPacketsKey(rawKey, 'month');
571
+ if (derived.from && derived.to) {
572
+ from = derived.from;
573
+ to = derived.to;
574
+ }
575
+ }
576
+ const key = _derivePacketsKey(from, to, rawKey);
500
577
  this.from = from;
501
578
  this.to = to;
502
579
  this._packetFiltered = !!(from || to);
@@ -573,7 +650,9 @@ export default {
573
650
  this.packetsStart = r.start;
574
651
  this.packetsEnd = r.end;
575
652
  this.packetDays = Array.isArray(r.days) && r.days.length ? r.days : null;
576
- if (this._packetFiltered && ((this.to && this.to < r.start) || (this.from && this.from > r.end))) {
653
+ const keyStr = typeof this.packetsKey === 'string' ? this.packetsKey : '';
654
+ const isNowKey = keyStr === 'now' || keyStr.startsWith('now:');
655
+ if (this._packetFiltered && !isNowKey && this.to && this.to < r.start) {
577
656
  this._initializingPackets = true;
578
657
  try {
579
658
  this._packetFiltered = false;
package/data/store.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "WORK-PAK-300A0DD0": {
4
4
  "licenseId": "WORK-PAK-300A0DD0",
5
5
  "issuedAt": 1793262666169,
6
- "updatedAt": 1793262666169,
6
+ "updatedAt": 1793271130179,
7
7
  "issuer": "work.odant.org",
8
8
  "customer": {
9
9
  "name": "ООО «Маслоторг»",
@@ -23,7 +23,7 @@
23
23
  "warrantyProvider": "Партнёр-интегратор WORK"
24
24
  },
25
25
  "partner": null,
26
- "baseSeats": 5,
26
+ "baseSeats": 10,
27
27
  "perpetualBase": true,
28
28
  "addonSubscriptions": [],
29
29
  "support": {
@@ -34,7 +34,7 @@
34
34
  },
35
35
  "protocol": "WORK-LIC-v1",
36
36
  "issuerPublicKey": "MCowBQYDK2VwAyEAWUaWWCwtQMu6P1vyw67NV4k1n+u2rWlKsYzqwMZWLBs=",
37
- "signature": "MrJ6/gykvxB9dP3nCqqhScggoWXb1k8xnHBEgCEbLP37htvCVij3epU6atmhp9UBx8Ua59OvcKGCKaPgNPQxCA=="
37
+ "signature": "JJ4b3YNiKbXO4enxRDYCVrE0+ZOSCpnaG268vW/RE/ucAFx+tJi9Xy4dLjfHogBxVw9Y5sAdivwCovq3yKTMBg=="
38
38
  }
39
39
  },
40
40
  "orders": {
@@ -406,6 +406,12 @@ ODA({
406
406
  this._nowItem.idleLabel = NOW_IDLE_LABELS[mode] || 'Месяц';
407
407
  this._nowItem.label = this._nowItem.idleLabel;
408
408
  }
409
+ },
410
+ _onValueChanged(value) {
411
+ if (!this._allItem) return;
412
+ const currentKey = this._selectionKey(this.selection);
413
+ if (value === currentKey) return;
414
+ this._applySelection(this._itemsByKey(value) ?? [this._allItem]);
409
415
  }
410
416
  },
411
417
  _hasCustomDays: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "odant-work",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "WORK — платформа цифровой организации (учётная система уровня 1С, документооборот, чаты и локальный мультимодальный ИИ)",
5
5
  "type": "module",
6
6
  "main": "sources/work.js",
@@ -473,6 +473,179 @@ function withTargetPaths(db, schema) {
473
473
  return { ...schema, fields: walk(schema.fields), overlay: walk(schema.overlay) };
474
474
  }
475
475
 
476
+ /**
477
+ * Собрать аналитические признаки по полям прилинкованного справочника слота счёта
478
+ * (Наименование + поля схемы $catalog, вложенных структур $struct и расширений OVERLAY у $virtual)
479
+ * со списком реально существующих значений для панели «Анализ счёта».
480
+ */
481
+ function buildCatalogAnalyticFields(db, slotField) {
482
+ const slotId = slotField.id;
483
+ const slotLabel = slotField.label || slotId;
484
+ const targetPath = slotField.targetPath || slotField.target;
485
+ const targetSchema = slotField.target ? withTargetPaths(db, classSchema(db, slotField.target)) : null;
486
+ if (!targetSchema) {
487
+ return [{
488
+ id: slotId,
489
+ spec: slotId,
490
+ slotId,
491
+ fieldId: 'name',
492
+ label: 'Наименование',
493
+ slotLabel,
494
+ isObject: true,
495
+ type: 'link',
496
+ catalog: targetPath,
497
+ options: [],
498
+ }];
499
+ }
500
+ const isVirt = targetSchema.type === '$virtual';
501
+ const regCid = isVirt ? (targetSchema.source?.registry || targetSchema.cid) : targetSchema.cid;
502
+ const descCids = descendants(db, regCid);
503
+
504
+ let objRows = [];
505
+ if (descCids.length) {
506
+ objRows = db.prepare(
507
+ `SELECT id, cid, body FROM objects WHERE cid IN (${descCids.map(() => '?').join(',')}) AND status <> 'deleted' AND ext_of IS NULL ORDER BY updated DESC LIMIT 300`
508
+ ).all(...descCids);
509
+ }
510
+ const extByObj = new Map();
511
+ if (isVirt) {
512
+ const virtCids = descendants(db, targetSchema.cid);
513
+ if (virtCids.length) {
514
+ for (const r of db.prepare(
515
+ `SELECT ext_of, body FROM objects WHERE cid IN (${virtCids.map(() => '?').join(',')}) AND status <> 'deleted' AND ext_of IS NOT NULL LIMIT 300`
516
+ ).all(...virtCids)) {
517
+ try {
518
+ extByObj.set(r.ext_of, JSON.parse(r.body || '{}'));
519
+ }
520
+ catch { /* ignore */ }
521
+ }
522
+ }
523
+ }
524
+
525
+ const parsedObjs = objRows.map(r => {
526
+ let body = {};
527
+ try { body = JSON.parse(r.body || '{}'); } catch { /* ignore */ }
528
+ const title = String(body.name || body.label || body.title || r.id).trim();
529
+ return { id: r.id, title, body, ext: extByObj.get(r.id) || null };
530
+ });
531
+
532
+ const objectOptions = parsedObjs
533
+ .map(o => ({ value: o.id, label: o.title || o.id }))
534
+ .sort((a, b) => a.label.localeCompare(b.label, 'ru', { numeric: true }));
535
+
536
+ const fieldDefs = [];
537
+ const seenFieldIds = new Set(['name', 'time']);
538
+ const collectFields = (list, isOverlay = false, prefix = '', prefixLabel = '') => {
539
+ for (const f of list || []) {
540
+ if (!f?.id || f.type === 'table')
541
+ continue;
542
+ const fullId = prefix ? `${prefix}.${f.id}` : f.id;
543
+ const fullLabel = prefixLabel ? `${prefixLabel}: ${f.label || f.id}` : (f.label || f.id);
544
+ if (f.type === 'struct' && Array.isArray(f.fields) && f.fields.length) {
545
+ collectFields(f.fields, isOverlay, fullId, fullLabel);
546
+ continue;
547
+ }
548
+ if (seenFieldIds.has(fullId))
549
+ continue;
550
+ seenFieldIds.add(fullId);
551
+ fieldDefs.push({
552
+ fieldId: fullId,
553
+ label: fullLabel,
554
+ type: f.type || 'string',
555
+ target: f.target,
556
+ values: f.values || f.options,
557
+ isOverlay,
558
+ });
559
+ }
560
+ };
561
+
562
+ collectFields(targetSchema.fields, false);
563
+ if (isVirt)
564
+ collectFields(targetSchema.overlay, true);
565
+ for (const cid of descCids) {
566
+ if (cid === targetSchema.cid)
567
+ continue;
568
+ const cs = classSchema(db, cid);
569
+ if (cs)
570
+ collectFields(cs.fields, false);
571
+ }
572
+
573
+ const refIdsToResolve = new Set();
574
+ const rawValuesByField = new Map();
575
+ for (const fd of fieldDefs) {
576
+ const vals = new Set();
577
+ for (const o of parsedObjs) {
578
+ let v;
579
+ if (fd.isOverlay && o.ext) {
580
+ v = o.ext[fd.fieldId];
581
+ }
582
+ else {
583
+ let cur = o.body;
584
+ for (const part of fd.fieldId.split('.')) {
585
+ if (!cur || typeof cur !== 'object') { cur = undefined; break; }
586
+ cur = cur[part];
587
+ }
588
+ v = cur;
589
+ }
590
+ if (v === undefined || v === null || v === '' || typeof v === 'object')
591
+ continue;
592
+ const strVal = String(v).trim();
593
+ if (!strVal)
594
+ continue;
595
+ vals.add(strVal);
596
+ if (fd.type === 'link')
597
+ refIdsToResolve.add(strVal);
598
+ }
599
+ rawValuesByField.set(fd.fieldId, [...vals]);
600
+ }
601
+
602
+ const refNames = refIdsToResolve.size ? namesOf(db, [...refIdsToResolve]).objects : {};
603
+
604
+ const out = [{
605
+ id: slotId,
606
+ spec: slotId,
607
+ slotId,
608
+ fieldId: 'name',
609
+ label: 'Наименование',
610
+ slotLabel,
611
+ isObject: true,
612
+ type: 'link',
613
+ catalog: targetPath,
614
+ options: objectOptions,
615
+ }];
616
+
617
+ for (const fd of fieldDefs) {
618
+ const rawVals = rawValuesByField.get(fd.fieldId) || [];
619
+ let options = [];
620
+ if (fd.type === 'boolean') {
621
+ options = [{ value: '1', label: 'Да' }, { value: '0', label: 'Нет' }];
622
+ }
623
+ else if (fd.type === 'enum' && Array.isArray(fd.values) && fd.values.length) {
624
+ options = fd.values.map(v => (v && typeof v === 'object'
625
+ ? { value: String(v.id ?? v.value), label: String(v.label ?? v.id ?? v.value) }
626
+ : { value: String(v), label: String(v) }));
627
+ }
628
+ else {
629
+ options = rawVals.map(v => ({
630
+ value: v,
631
+ label: fd.type === 'link' ? (refNames[v] || v) : v,
632
+ })).sort((a, b) => a.label.localeCompare(b.label, 'ru', { numeric: true }));
633
+ }
634
+ out.push({
635
+ id: `${slotId}.${fd.fieldId}`,
636
+ spec: `${slotId}.${fd.fieldId}`,
637
+ slotId,
638
+ fieldId: fd.fieldId,
639
+ label: fd.label,
640
+ slotLabel,
641
+ isObject: false,
642
+ type: fd.type,
643
+ options,
644
+ });
645
+ }
646
+ return out;
647
+ }
648
+
476
649
  /**
477
650
  * Поддерево класса для форм: `classes` (cid → описание потомков), `leaves` (листья для создания
478
651
  * объектов в группе) и `slots` (слоты аналитик счёта или журнала по всему поддереву счетов).
@@ -508,7 +681,13 @@ function schemaSubtree(db, cls) {
508
681
  for (const f of analyticSlots(s || { fields: [] })) {
509
682
  if (!seenSlots.has(f.id)) {
510
683
  seenSlots.add(f.id);
511
- slots.push({ id: f.id, label: f.label || f.id, target: f.target, targetPath: f.targetPath || f.target });
684
+ slots.push({
685
+ id: f.id,
686
+ label: f.label || f.id,
687
+ target: f.target,
688
+ targetPath: f.targetPath || f.target,
689
+ fields: buildCatalogAnalyticFields(db, f),
690
+ });
512
691
  }
513
692
  }
514
693
  }
@@ -1,6 +1,43 @@
1
1
  # Прогресс: sources/data
2
2
 
3
3
  ## Последние изменения
4
+ - [14:30, 29.10] Обновление комплектации и лицензионного центра **ODANT-WORK** (`odant-work@1.0.2`, `work-license-server@1.0.3`):
5
+ - **10 вечных лицензий + 1 год поддержки Enterprise в комплекте (`license-server/src/catalog.js`, `sources/modules/license/index.js`, `store.js`, `form/license`):**
6
+ 1. В базовую поставку комплекса (`5 000 000 ₽`) включены **10 вечных одновременных лицензий** (`baseSeats: 10`) и **1-й год поддержки максимального уровня Enterprise** (`includedSupportTier: 'enterprise'`, 24/7, реакция до 15 минут, безлимитные обращения).
7
+ 2. Дальнейшее продление технической поддержки и докупка дополнительных годовых мест — **по желанию клиента** (10 вечных лицензий работают бессрочно).
8
+ - **Гибридная политика моделей ИИ (`catalog.js`, `landing.js`, `agent.js`, `form/license`):**
9
+ 1. Явно зафиксировано, что 4 интегрированные локальные модели предназначены для **базовых ежедневных операций** внутри контура предприятия.
10
+ 2. Если требуются более мощные модели ИИ, их можно свободно подключать **через стандартный API из любого источника** (внешние облачные провайдеры, корпоративный GPU-кластер или коммерческий API регионального партнёра).
11
+ - **Интеграция ИИ-агента на левую вкладку админ-панели (`/admin`, `license-server/src/landing.js`, `assets/landing.css`, `assets/landing.js`, `src/agent.js`):**
12
+ 1. В `/admin` создана двухколоночная рабочая область `.admin-workspace` с левой панелью навигации по вкладкам (`ИИ-агент + Все разделы`, `Только ИИ-агент`, `Лицензии`, `Заказы ЮKassa`, `Франшизы и партнёры`, `Тикеты SLA`) и встроенным на левую вкладку **ИИ-агентом администратора** (`#admAgentCard`).
13
+ 2. ИИ-агент наделён административными инструментами: формирование живой сводки по реестру сервера, выпуск и подпись сертификатов `Ed25519`, проведение оплаты ожидающих заказов ЮKassa и проверка квот франшиз с автоматическим обновлением таблиц админки.
14
+ - [14:15, 29.10] Доработка пакетировщика `oda-packets`, навигации по вкладкам в подвале `work-form`, представлений «Обороты» (`form/turnovers`), «Анализ» (`form/analysis`) и стандартной вьюхи редактора объекта (`form/object` с `allowTools: false`):
15
+ - **Расчёт сальдо «На начало» при выборе текущего периода в `oda-packets` (`sheet.js`, `postings.js`, `journal.js`, `oda/components/packets/packets.js`):**
16
+ 1. В `_syncPacketRange(res)` убран принудительный сброс `from`/`to` на «Весь период», когда начало выбранного периода лежит позже последней проводки в базе (`this.from > r.end`, например при выборе `4 кв. 2026 г.`, `Сегодня`, `Текущая неделя`, `Текущий месяц`, `Текущий квартал`): отчёт теперь сохраняет границы периода и выводит реальное входящее сальдо «На начало» и исходящее «На конец».
17
+ 2. В `_restorePeriodState` добавлено вычисление границ `_boundsFromPacketsKey` для ключей `now` / `now:*` / масок периодов, а в `oda-packets` добавлен наблюдатель `_onValueChanged` для внешней синхронизации `value`.
18
+ - **Навигация внутри `work-form` на вкладках справа в футере без создания новых `iframe` (`$server/$folder/handlers/pages/form/$handler/form.js`, `$server/$folder/lib/tools/tools.js`):**
19
+ 1. В `work-form` добавлен стек вкладок `formTabs` и метод `openInForm(targetItem, nav)`, перехватывающий переходы внутри формы (`window.execute`, переходы к счетам, подклассам и объектам).
20
+ 2. При открытии 2 и более экранов (`formTabs.length > 1`) справа на футере (`#formFooter`) отображается полоса вкладок `.form-tabs-bar` с мгновенным переключением и закрытием вкладок `×`.
21
+ 3. Из `#formFooter` убраны сервисные кнопки от `$item` самой формы (`#mainTools`) — сервисные кнопки отображаются строго от `$focusedItem` (`#focusedTools`), поэтому на представлениях счёта (`Обороты`, `Анализ`, `Проводки`) они не дублируются снизу.
22
+ - **Представление «Обороты» (`form/turnovers` в `postings.js`):**
23
+ 1. Выбор группировки (`Нет / Дни / Недели / Месяцы / Кварталы / Годы / Операции / Счета / Все`) перенесён компактным дропдауном (`<select title="Группировать">`) в верхнюю полосу `.filter-strip`, боковая панель `.group-panel` убрана.
24
+ 2. В правой таблице документов (`.docs-panel`) последним полем добавлена узкая колонка с кнопкой-иконкой проводки (`icons:list` без текста) для открытия карточки проводки.
25
+ - **Представление «Анализ» (`form/analysis` в `postings.js`, `sources/data/api.js` → `buildCatalogAnalyticFields`):**
26
+ 1. В `db_schema` (`schemaSubtree`) для каждого аналитического слота счёта собираются поля прилинкованного справочника (`Наименование` + поля схемы `$catalog`, вложенных структур `$struct` и `OVERLAY` у `$virtual`) с реально существующими значениями `options`.
27
+ 2. Слева на вьюхе «Анализ» вместо устаревших карточек слотов оформлена панель аналитических признаков (`Аналитика | Значение`) с выбором значений полей справочника (`setFeatureValue`) и перестановкой порядка `↑↓` (`moveFeature`), а строка быстрого фильтра встроена под шапкой таблицы без лишней верхней панели `.analysis-toolbar`.
28
+ - **Стандартная вьюха редактора объекта (`$server/$folder/$class/handlers/pages/form/object/$handler/{class.js,object.js}`, `objects.js`, `form.js`):**
29
+ 1. Создан штатный хендлер представления объекта `form/object` с атрибутом `allowTools: false`, отключающим верхний тулбар хендлеров в `work-form`.
30
+ 2. Вьюха `item-object` рендерится прямо в `work-form` на вкладке (с сервисными кнопками `Провести / Проведен / Отменить`, `Печать`, `Удалить / Восстановить` слева в подвале и кнопками `OK / 💾 / Закрыть` справа в подвале).
31
+ - [12:00, 29.10] Обновление лицензионного сервера `work.odant.org` (`work-license-server@1.0.1`), витрины ПАК **ODANT-WORK** (`odant-work@1.0.1`), интерактивной карты регионов РФ и панели администратора (`/admin`):
32
+ - **Брендинг и паспорт комплекса ODANT-WORK (`license-server/src/catalog.js`, `sources/modules/license/index.js`, `form/license`):**
33
+ 1. Фирменный логотип ODANT (`favicon.ico` → `/assets/odant-logo.png`), полное исключение упоминаний «1С» и брендов моделей на витрине (указаны типы и назначение 4 предустановленных локальных моделей: языковая LLM, голосовая ASR/TTS, генерация изображений, векторный поиск / RAG).
34
+ 2. Точная спецификация платформы **AMD Ryzen™ AI Max+ PRO 495** (Zen 5, 16 ядер / 32 потока, до 5,2 ГГц, 80 МБ кэша), **AMD Radeon™ 8065S Graphics** (40 ядер, 3000 МГц, до 61 TFLOPS FP16), **до 132 TOPS** (NPU 55 TOPS), **192 ГБ LPDDR5x-8533 Unified RAM**, **4 ТБ NVMe PCIe 4.0 SSD**, **AMD PRO / AMD Memory Guard**.
35
+ 3. Стоимость базового комплекса — `5 000 000 ₽` (включая вечную лицензию на `5` одновременных сессий и 1-й год поддержки), тарифы дополнительных годовых мест по SLA: **Start** — `10 000 ₽/год`, **Business** — `15 000 ₽/год`, **Enterprise** — `20 000 ₽/год` со скидкой от объёма до `−25%`; раздел «Докупка лицензий» доступен только авторизованным пользователям.
36
+ - **Вкладка «ИИ-агенты», интерактивная карта субъектов РФ и админ-панель `/admin` (`license-server/src/landing.js`, `assets/landing.css`, `assets/landing.js`, `assets/russia-regions.json`, `src/agent.js`):**
37
+ 1. Раздел **«ИИ-агенты»**: демонстрация встроенного ИИ-консультанта (кнопка `?` в каждой форме, полнодуплексный голосовой диалог, ролевые инструменты) и интерактивная орбитальная схема мультиагентной системы из `13` ролевых субагентов и `26+` навыков.
38
+ 2. **Интерактивная карта субъектов РФ (`assets/russia-regions.json`, `scripts/build-russia-map.mjs`):** 85 векторных контуров из OpenStreetMap в проекции Альберса с топологическим упрощением, фильтр по федеральным округам, масштабирование/перетаскивание, расчёт квот франшиз по населению, базового паушального взноса `500 000 ₽ × индекс региона`, резервирования со счётом ЮKassa на невозвратный залог `100 000 ₽` и полной оплаты паушального взноса в течение 1 года (`POST /api/partners/reserve`).
39
+ 3. **Админ-панель сервера лицензий (`/admin`, `#admin`):** сводные KPI, форма выпуска и подписи сертификатов `Ed25519` (`WORK-LIC-v1`), таблицы выпущенных лицензий с просмотром JSON-сертификата, заказов ЮKassa с проведением оплаты в один клик, резервов региональных франшиз / партнёров и тикетов техподдержки (`MAX`, `Telegram`, `VK`) с ответом инженера.
40
+ 4. Реквизиты разработчика **«Бизнес Интер Софт»** (`390000, г. Рязань, ул. Ленина, 47`, `+7 (4912) 77-70-21`, `+7 (4912) 77-70-22`, `odant.ru`), мягкий деловой анимированный фон, тёмная/светлая темы и публикация пакетов `odant-work@1.0.1` и `work-license-server@1.0.1` в npm.
4
41
  - [16:40, 28.10] Полнодуплексный диалоговый механизм с взаимным перебиванием (User Barge-In & AI Barge-In, как в ChatGPT Voice) в `.task`, `item-consultant` и `form/chat`:
5
42
  - **Мгновенное перебивание ИИ человеком (`sources/modules/agent/session.js`, `loop.js`, `index.js`, `ui/voice.js`, `ui/panel.js`, `ui/feed.js`):**
6
43
  1. В `session.prompt` добавлена поддержка `interrupt: true` и `spokenText`, а также отдельный метод `interrupt`: при перебивании (голосом в момент начала фразы или текстом в режиме диалога) активный стрим модели немедленно обрывается (`s.controller.abort('barge_in')`), ответ ассистента обрезается до реально прозвучавшей части (`spokenText`) с пометкой `interrupted: true` (`· прервано собеседником` в ленте и `[прервано собеседником]` в контексте `toMessages`), и цикл без паузы переключается на новую реплику человека.
@@ -5,7 +5,7 @@
5
5
  * - паспорт программно-аппаратного комплекса (AMD Ryzen™ AI Max+ PRO 495, 192 ГБ RAM, SSD 4 ТБ)
6
6
  * и реестр предустановленных моделей ИИ (Qwen 3 30B + Голосовая + Генерация изображений + Embedding);
7
7
  * - хранение и криптографическую проверку сертификата лицензии Ed25519 (#system/license.json);
8
- * - учёт рабочих мест: 5 вечных (базовый комплект ПАК) + срочные годовые лицензии (Start / Business / Enterprise);
8
+ * - учёт рабочих мест: 10 вечных (базовый комплект ПАК + 1 год поддержки Enterprise) + срочные годовые лицензии по желанию (Start / Business / Enterprise);
9
9
  * - контроль лимита одновременных пользовательских сессий при входе в WORK;
10
10
  * - взаимодействие с лицензионным сервером https://work.odant.org (синхронизация, покупка через ЮKassa,
11
11
  * привязка ботов поддержки MAX, Telegram, VK).
@@ -34,10 +34,12 @@ export const PAK_HARDWARE_SPEC = Object.freeze({
34
34
  ssdTb: 4,
35
35
  security: 'AMD PRO Technologies, AMD Memory Guard, AMD Secure Processor',
36
36
  basePriceRub: 5_000_000,
37
- baseSeats: 5,
37
+ baseSeats: 10,
38
38
  perpetualBase: true,
39
39
  includedSupportYears: 1,
40
- includedSupportTier: 'business',
40
+ includedSupportTier: 'enterprise',
41
+ postYearPolicy: 'Дальнейшее продление поддержки и докупка мест — по желанию (10 вечных лицензий работают бессрочно)',
42
+ modelsPolicy: 'Интегрированные локальные модели предназначены для базовых операций; более мощные ИИ можно подключать через API из любого источника',
41
43
  configuration: 'Встроенная система учёта, документооборота, управления процессами и приватный мультимодальный ИИ (справочники, план счетов, двойная запись, ведомости, первичные документы, сквозные процессы и корпоративные коммуникации)',
42
44
  preinstalledModels: Object.freeze([
43
45
  {
@@ -301,8 +303,8 @@ export function evaluateLicenseState(lic = loadLocalLicense(), now = Date.now())
301
303
  const baseSupportActive = baseSupportUntil > now;
302
304
 
303
305
  if (baseSupportActive) {
304
- const baseTierKey = String(lic.support?.tier || PAK_HARDWARE_SPEC.includedSupportTier || 'business').toLowerCase();
305
- const baseTierSpec = SUPPORT_TIERS[baseTierKey] || SUPPORT_TIERS.business;
306
+ const baseTierKey = String(lic.support?.tier || PAK_HARDWARE_SPEC.includedSupportTier || 'enterprise').toLowerCase();
307
+ const baseTierSpec = SUPPORT_TIERS[baseTierKey] || SUPPORT_TIERS.enterprise;
306
308
  highestTier = baseTierKey;
307
309
  totalTicketsQuota += baseSeats * baseTierSpec.ticketsPerSeatYear;
308
310
  nearestExpiry = baseSupportUntil;