@alizadefaig/pos-server 1.2.25 → 1.2.27

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.
Files changed (116) hide show
  1. package/app/core/Context.js +170 -0
  2. package/app/core/auth/index.js +133 -0
  3. package/app/core/auth/license.js +45 -0
  4. package/app/core/auth/posMode.js +260 -0
  5. package/app/core/auth/sanctum.js +209 -0
  6. package/app/core/auth/scope.js +231 -0
  7. package/app/core/emitter.js +67 -0
  8. package/app/core/errors.js +18 -0
  9. package/app/core/guard.js +54 -0
  10. package/app/core/idempotency.js +196 -0
  11. package/app/core/index.js +374 -0
  12. package/app/core/models/Order.js +176 -0
  13. package/app/core/models/OrderProduct.js +249 -0
  14. package/app/core/repositories/EventRepository.js +51 -0
  15. package/app/core/repositories/OrderProductRepository.js +225 -0
  16. package/app/core/repositories/OrderRepository.js +274 -0
  17. package/app/core/repositories/OutboxRepository.js +52 -0
  18. package/app/core/repositories/PaymentRepository.js +43 -0
  19. package/app/core/repositories/PrinterRepository.js +179 -0
  20. package/app/core/repositories/ProductRelations.js +148 -0
  21. package/app/core/repositories/SnapshotRepository.js +197 -0
  22. package/app/core/resources/HallResource.js +54 -0
  23. package/app/core/resources/OrderResource.js +177 -0
  24. package/app/core/resources/ProductResource.js +186 -0
  25. package/app/core/resources/ShiftResource.js +42 -0
  26. package/app/core/resources/TeamResource.js +85 -0
  27. package/app/core/resources/casts.js +55 -0
  28. package/app/core/services/AuthService.js +468 -0
  29. package/app/core/services/InitService.js +277 -0
  30. package/app/core/services/KdsService.js +639 -0
  31. package/app/core/services/OrderProductService.js +309 -0
  32. package/app/core/services/OrderService.js +903 -0
  33. package/app/core/services/PrintService.js +715 -0
  34. package/app/core/services/PrinterAdminService.js +324 -0
  35. package/app/core/services/ReportService.js +581 -0
  36. package/app/core/services/ShiftService.js +199 -0
  37. package/app/core/services/StorageService.js +425 -0
  38. package/app/core/services/TelegramService.js +162 -0
  39. package/app/core/support/db.js +48 -0
  40. package/app/core/support/ids.js +9 -0
  41. package/app/core/support/imageData.js +135 -0
  42. package/app/core/support/images.js +92 -0
  43. package/app/core/support/lanHandoff.js +105 -0
  44. package/app/core/support/lang.js +98 -0
  45. package/app/core/support/lock.js +63 -0
  46. package/app/core/support/num.js +51 -0
  47. package/app/core/support/php.js +125 -0
  48. package/app/core/support/pin.js +34 -0
  49. package/app/core/support/receipts.js +278 -0
  50. package/app/core/support/rows.js +38 -0
  51. package/app/core/support/str.js +31 -0
  52. package/app/core/support/teamSettings.js +77 -0
  53. package/app/core/support/telegram.js +253 -0
  54. package/app/core/support/telegramReport.js +143 -0
  55. package/app/core/support/time.js +58 -0
  56. package/app/core/support/tz.js +80 -0
  57. package/app/core/support/unlock.js +113 -0
  58. package/app/core/timers.js +151 -0
  59. package/app/db/Database.js +197 -1
  60. package/app/db/Migrator.js +45 -0
  61. package/app/db/driver.js +248 -0
  62. package/app/db/migrations/001_snapshot.sql +214 -0
  63. package/app/db/migrations/002_orders.sql +190 -0
  64. package/app/db/migrations/003_idempotency.sql +18 -0
  65. package/app/db/migrations/004_shifts.sql +21 -0
  66. package/app/db/migrations/005_printers.sql +43 -0
  67. package/app/db/migrations/006_sync.sql +136 -0
  68. package/app/db/migrations/007_printer_mac.sql +4 -0
  69. package/app/db/migrations/008_kds.sql +94 -0
  70. package/app/db/migrations/009_product_categories.sql +20 -0
  71. package/app/db/migrations/010_printers_sync.sql +43 -0
  72. package/app/db/migrations/011_team_logos.sql +13 -0
  73. package/app/db/migrations/012_category_workshop.sql +4 -0
  74. package/app/monitor.js +185 -0
  75. package/app/printers/address.js +348 -0
  76. package/app/proxy.js +20 -3
  77. package/app/relay/masters.js +173 -0
  78. package/app/security.js +218 -0
  79. package/app/socket/auth.js +160 -0
  80. package/app/socket/request.js +87 -0
  81. package/app/socket/server.js +121 -19
  82. package/app/sync/bridge.js +162 -0
  83. package/app/sync/cloud.js +120 -0
  84. package/app/sync/config.js +185 -0
  85. package/app/sync/discovery.js +546 -0
  86. package/app/sync/index.js +1644 -0
  87. package/app/sync/kv.js +26 -0
  88. package/app/sync/lan.js +139 -0
  89. package/app/sync/lanDevices.js +177 -0
  90. package/app/sync/lanFront.js +431 -0
  91. package/app/sync/mirror.js +236 -0
  92. package/app/sync/push.js +324 -0
  93. package/app/sync/reconcile.js +161 -0
  94. package/app/sync/signal.js +202 -0
  95. package/app/sync/snapshot.js +189 -0
  96. package/app/sync/store.js +234 -0
  97. package/app/tcp.js +11 -2
  98. package/package.json +6 -3
  99. package/routes/common/api/auth.js +38 -0
  100. package/routes/common/api/kds.js +37 -0
  101. package/routes/common/api/orderProducts.js +81 -0
  102. package/routes/common/api/orders.js +176 -0
  103. package/routes/common/api/printersCore.js +35 -0
  104. package/routes/common/api/reports.js +29 -0
  105. package/routes/common/socket.js +15 -35
  106. package/routes/common/tax.js +35 -7
  107. package/routes/desktop/api/offline.js +146 -0
  108. package/routes/desktop/api/printers.js +288 -5
  109. package/routes/desktop/local.js +130 -0
  110. package/routes/server/api/login.js +37 -0
  111. package/routes/server/api/platform.js +31 -0
  112. package/routes/server/api/shifts.js +25 -0
  113. package/routes/server/master.js +24 -0
  114. package/server.js +111 -4
  115. package/utils.js +6 -0
  116. package/tests/usbPrinterTest.js +0 -20
@@ -0,0 +1,43 @@
1
+ -- Принтеры для офлайн-печати (sendToWorkshops / printBillReceipt в ядре).
2
+ -- Наполняет их синк снапшота, как и остальные справочники: печать решает, КУДА слать чек
3
+ -- (терминал + физический принтер), а сама печать остаётся на терминале через сокет.
4
+ -- Колонки повторяют Postgres-схему Laravel один в один — синк копирует строки
5
+ -- по пересечению колонок, без маппинга.
6
+
7
+ CREATE TABLE IF NOT EXISTS printers (
8
+ id TEXT PRIMARY KEY,
9
+ name TEXT,
10
+ -- usb | lan: по типу терминал выбирает транспорт
11
+ type TEXT,
12
+ ip_address TEXT,
13
+ vendor_id TEXT,
14
+ product_id TEXT,
15
+ -- терминал, к которому физически подключён принтер (на него уходит сокет-запрос)
16
+ terminal_id TEXT,
17
+ team_id TEXT,
18
+ company_id TEXT,
19
+ -- принтер счёта (чек гостю); цеховые принтеры идут через printer_workshop
20
+ bill INTEGER DEFAULT 0,
21
+ bell INTEGER DEFAULT 1,
22
+ -- открыть денежный ящик при печати оплаченного счёта (на пречеке — никогда)
23
+ cashdraw INTEGER DEFAULT 0,
24
+ deleted_at TEXT,
25
+ created_at TEXT,
26
+ updated_at TEXT,
27
+ synced_at TEXT
28
+ );
29
+
30
+ CREATE INDEX IF NOT EXISTS idx_printers_terminal ON printers (terminal_id);
31
+
32
+ -- Какие цеха печатает принтер. Своего id и timestamps у связи в Laravel нет; уникальности
33
+ -- тоже нет — повторы в облаке возможны, и локально они должны вести себя так же (два задания).
34
+ CREATE TABLE IF NOT EXISTS printer_workshop (
35
+ printer_id TEXT NOT NULL,
36
+ product_workshop_id TEXT NOT NULL,
37
+ -- выключенная связь хранится, но цеховой чек на этот принтер не уходит
38
+ print_ticket INTEGER DEFAULT 0,
39
+ synced_at TEXT
40
+ );
41
+
42
+ CREATE INDEX IF NOT EXISTS idx_printer_workshop_printer ON printer_workshop (printer_id);
43
+ CREATE INDEX IF NOT EXISTS idx_printer_workshop_workshop ON printer_workshop (product_workshop_id);
@@ -0,0 +1,136 @@
1
+ -- Синк локального Node с облаком (OFFLINE-FIRST-PLAN §11, модель F).
2
+ --
3
+ -- Облако — основная система, локальная БД — зеркало на случай обрыва связи. Всё, что касса
4
+ -- изменила сама (офлайн), помечается «грязным» и уезжает наверх агрегатом чека; пока пометка
5
+ -- висит, зеркало этот чек не перезаписывает — иначе офлайн-правки затёр бы старый облачный снимок.
6
+
7
+ -- Мелкое состояние синка: сессия терминала, курсор зеркала, blob ответа app.init, времена.
8
+ CREATE TABLE IF NOT EXISTS kv (
9
+ key TEXT PRIMARY KEY,
10
+ value TEXT,
11
+ updated_at TEXT
12
+ );
13
+
14
+ -- Что изменено локально и ещё не подтверждено облаком. version растёт на каждой записи:
15
+ -- пометку снимаем, только если после отправки её никто не трогал (DELETE ... AND version = ?),
16
+ -- поэтому правка, прилетевшая во время push, не потеряется.
17
+ CREATE TABLE IF NOT EXISTS sync_dirty (
18
+ entity TEXT NOT NULL, -- 'order' | 'shift'
19
+ id TEXT NOT NULL,
20
+ version INTEGER NOT NULL DEFAULT 1,
21
+ dirty_at TEXT,
22
+ PRIMARY KEY (entity, id)
23
+ );
24
+
25
+ -- Пока здесь есть строка, триггеры молчат: так зеркало пишет облачные данные, не помечая их
26
+ -- грязными. Ставится и снимается внутри одного синхронного блока (см. app/sync/mirror.js),
27
+ -- поэтому запись ядра в этот момент вклиниться не может.
28
+ CREATE TABLE IF NOT EXISTS sync_suppress (
29
+ id INTEGER PRIMARY KEY CHECK (id = 1)
30
+ );
31
+
32
+ -- Язык команды: в облаке это teams.language_id → languages.code (см. Context::resolveLocale).
33
+ -- Снапшот кладёт обе таблицы как есть, language_code выводится при применении снапшота.
34
+ CREATE TABLE IF NOT EXISTS languages (
35
+ id TEXT PRIMARY KEY,
36
+ code TEXT,
37
+ name TEXT,
38
+ synced_at TEXT
39
+ );
40
+
41
+ ALTER TABLE teams ADD COLUMN language_id TEXT;
42
+ ALTER TABLE teams ADD COLUMN user_id TEXT;
43
+
44
+ -- Офлайн-вход по PIN / карте: ищем сотрудника в ответе app.init, но смену (cash/work) ищем
45
+ -- по team_users/shifts — колонки нужны для этого поиска и для паритета со снапшотом.
46
+ ALTER TABLE team_users ADD COLUMN pin TEXT;
47
+ ALTER TABLE team_users ADD COLUMN ic_card TEXT;
48
+ ALTER TABLE team_users ADD COLUMN deleted_at TEXT;
49
+
50
+ -- ------------------------------------------------------------------ триггеры грязных чеков
51
+
52
+ -- Одна и та же вставка для всех триггеров: пометить чек (или смену) и поднять версию.
53
+
54
+ CREATE TRIGGER IF NOT EXISTS sync_dirty_orders_ins AFTER INSERT ON orders
55
+ WHEN NOT EXISTS (SELECT 1 FROM sync_suppress)
56
+ BEGIN
57
+ INSERT INTO sync_dirty (entity, id, version, dirty_at) VALUES ('order', NEW.id, 1, datetime('now'))
58
+ ON CONFLICT (entity, id) DO UPDATE SET version = version + 1, dirty_at = excluded.dirty_at;
59
+ END;
60
+
61
+ CREATE TRIGGER IF NOT EXISTS sync_dirty_orders_upd AFTER UPDATE ON orders
62
+ WHEN NOT EXISTS (SELECT 1 FROM sync_suppress)
63
+ BEGIN
64
+ INSERT INTO sync_dirty (entity, id, version, dirty_at) VALUES ('order', NEW.id, 1, datetime('now'))
65
+ ON CONFLICT (entity, id) DO UPDATE SET version = version + 1, dirty_at = excluded.dirty_at;
66
+ END;
67
+
68
+ CREATE TRIGGER IF NOT EXISTS sync_dirty_order_products_ins AFTER INSERT ON order_products
69
+ WHEN NOT EXISTS (SELECT 1 FROM sync_suppress)
70
+ BEGIN
71
+ INSERT INTO sync_dirty (entity, id, version, dirty_at) VALUES ('order', NEW.order_id, 1, datetime('now'))
72
+ ON CONFLICT (entity, id) DO UPDATE SET version = version + 1, dirty_at = excluded.dirty_at;
73
+ END;
74
+
75
+ -- merge/transfer переносят позицию в другой чек (UPDATE order_id): грязными становятся оба —
76
+ -- исходный теряет позицию, целевой её получает.
77
+ CREATE TRIGGER IF NOT EXISTS sync_dirty_order_products_upd AFTER UPDATE ON order_products
78
+ WHEN NOT EXISTS (SELECT 1 FROM sync_suppress)
79
+ BEGIN
80
+ INSERT INTO sync_dirty (entity, id, version, dirty_at) VALUES ('order', NEW.order_id, 1, datetime('now'))
81
+ ON CONFLICT (entity, id) DO UPDATE SET version = version + 1, dirty_at = excluded.dirty_at;
82
+ INSERT INTO sync_dirty (entity, id, version, dirty_at) VALUES ('order', OLD.order_id, 1, datetime('now'))
83
+ ON CONFLICT (entity, id) DO UPDATE SET version = version + 1, dirty_at = excluded.dirty_at;
84
+ END;
85
+
86
+ -- У модификатора нет order_id — чек находим через позицию.
87
+ CREATE TRIGGER IF NOT EXISTS sync_dirty_opms_ins AFTER INSERT ON order_product_modifier_sets
88
+ WHEN NOT EXISTS (SELECT 1 FROM sync_suppress)
89
+ BEGIN
90
+ INSERT INTO sync_dirty (entity, id, version, dirty_at)
91
+ SELECT 'order', op.order_id, 1, datetime('now') FROM order_products op WHERE op.id = NEW.order_product_id
92
+ ON CONFLICT (entity, id) DO UPDATE SET version = version + 1, dirty_at = excluded.dirty_at;
93
+ END;
94
+
95
+ CREATE TRIGGER IF NOT EXISTS sync_dirty_opms_upd AFTER UPDATE ON order_product_modifier_sets
96
+ WHEN NOT EXISTS (SELECT 1 FROM sync_suppress)
97
+ BEGIN
98
+ INSERT INTO sync_dirty (entity, id, version, dirty_at)
99
+ SELECT 'order', op.order_id, 1, datetime('now') FROM order_products op WHERE op.id = NEW.order_product_id
100
+ ON CONFLICT (entity, id) DO UPDATE SET version = version + 1, dirty_at = excluded.dirty_at;
101
+ END;
102
+
103
+ CREATE TRIGGER IF NOT EXISTS sync_dirty_order_payments_ins AFTER INSERT ON order_payments
104
+ WHEN NOT EXISTS (SELECT 1 FROM sync_suppress)
105
+ BEGIN
106
+ INSERT INTO sync_dirty (entity, id, version, dirty_at) VALUES ('order', NEW.order_id, 1, datetime('now'))
107
+ ON CONFLICT (entity, id) DO UPDATE SET version = version + 1, dirty_at = excluded.dirty_at;
108
+ END;
109
+
110
+ CREATE TRIGGER IF NOT EXISTS sync_dirty_order_payments_upd AFTER UPDATE ON order_payments
111
+ WHEN NOT EXISTS (SELECT 1 FROM sync_suppress)
112
+ BEGIN
113
+ INSERT INTO sync_dirty (entity, id, version, dirty_at) VALUES ('order', NEW.order_id, 1, datetime('now'))
114
+ ON CONFLICT (entity, id) DO UPDATE SET version = version + 1, dirty_at = excluded.dirty_at;
115
+ END;
116
+
117
+ CREATE TRIGGER IF NOT EXISTS sync_dirty_order_events_ins AFTER INSERT ON order_events
118
+ WHEN NOT EXISTS (SELECT 1 FROM sync_suppress) AND NEW.order_id IS NOT NULL
119
+ BEGIN
120
+ INSERT INTO sync_dirty (entity, id, version, dirty_at) VALUES ('order', NEW.order_id, 1, datetime('now'))
121
+ ON CONFLICT (entity, id) DO UPDATE SET version = version + 1, dirty_at = excluded.dirty_at;
122
+ END;
123
+
124
+ CREATE TRIGGER IF NOT EXISTS sync_dirty_shifts_ins AFTER INSERT ON shifts
125
+ WHEN NOT EXISTS (SELECT 1 FROM sync_suppress)
126
+ BEGIN
127
+ INSERT INTO sync_dirty (entity, id, version, dirty_at) VALUES ('shift', NEW.id, 1, datetime('now'))
128
+ ON CONFLICT (entity, id) DO UPDATE SET version = version + 1, dirty_at = excluded.dirty_at;
129
+ END;
130
+
131
+ CREATE TRIGGER IF NOT EXISTS sync_dirty_shifts_upd AFTER UPDATE ON shifts
132
+ WHEN NOT EXISTS (SELECT 1 FROM sync_suppress)
133
+ BEGIN
134
+ INSERT INTO sync_dirty (entity, id, version, dirty_at) VALUES ('shift', NEW.id, 1, datetime('now'))
135
+ ON CONFLICT (entity, id) DO UPDATE SET version = version + 1, dirty_at = excluded.dirty_at;
136
+ END;
@@ -0,0 +1,4 @@
1
+ -- MAC сетевого принтера (привязка по MAC, фронт с VITE_PRINTER_MAC): колонка как в Laravel
2
+ -- (миграция add_mac_address_to_printers_table). Снапшот копирует её вместе с остальными, а печать
3
+ -- ядра отдаёт терминалу в payload принтера — локальный Node находит принтер, сменивший IP.
4
+ ALTER TABLE printers ADD COLUMN mac_address TEXT;
@@ -0,0 +1,94 @@
1
+ -- KDS (кухонный экран) и экран готовности без облака: порт KdsService из Laravel.
2
+ --
3
+ -- Тикеты создаёт ядро в момент отправки в цех (PrintService.sendToWorkshops), статусы ведёт
4
+ -- KDS-терминал, при закрытии/удалении чека тикеты гасятся (soft delete). Колонки повторяют
5
+ -- Postgres-схему Laravel один в один — в облачном контуре ядро пишет в те же таблицы.
6
+
7
+ CREATE TABLE IF NOT EXISTS kds_tickets (
8
+ id TEXT PRIMARY KEY,
9
+ company_id TEXT,
10
+ team_id TEXT,
11
+ order_id TEXT,
12
+ product_workshop_id TEXT,
13
+ -- порядковый номер отправки в рамках чека; удалённые тикеты номера не освобождают
14
+ number INTEGER DEFAULT 1,
15
+ -- waiting | cooking | ready
16
+ status TEXT DEFAULT 'waiting',
17
+ -- 0 — цех без KDS-терминала: тикет сразу «готов» и готовность чека не держит
18
+ tracked INTEGER DEFAULT 1,
19
+ fired_at TEXT,
20
+ ready_at TEXT,
21
+ deleted_at TEXT,
22
+ created_at TEXT,
23
+ updated_at TEXT
24
+ );
25
+
26
+ CREATE INDEX IF NOT EXISTS idx_kds_tickets_order ON kds_tickets (order_id);
27
+ CREATE INDEX IF NOT EXISTS idx_kds_tickets_company_workshop ON kds_tickets (company_id, product_workshop_id);
28
+
29
+ CREATE TABLE IF NOT EXISTS kds_ticket_items (
30
+ id TEXT PRIMARY KEY,
31
+ kds_ticket_id TEXT,
32
+ order_product_id TEXT,
33
+ product_id TEXT,
34
+ -- снимок названия на момент отправки
35
+ name TEXT,
36
+ quantity REAL DEFAULT 1,
37
+ -- позицию убавили/удалили после отправки: сколько из quantity отменено
38
+ cancelled_quantity REAL DEFAULT 0,
39
+ comment TEXT,
40
+ status TEXT DEFAULT 'waiting',
41
+ ready_at TEXT,
42
+ cancelled_at TEXT,
43
+ created_at TEXT,
44
+ updated_at TEXT
45
+ );
46
+
47
+ CREATE INDEX IF NOT EXISTS idx_kds_ticket_items_ticket ON kds_ticket_items (kds_ticket_id);
48
+ CREATE INDEX IF NOT EXISTS idx_kds_ticket_items_order_product ON kds_ticket_items (order_product_id);
49
+
50
+ -- Какие цеха показывает KDS-терминал. Как и printer_workshop, своего id у связи нет;
51
+ -- наполняет её снапшот (Laravel уже отдаёт terminal_workshop по терминалам заведения).
52
+ CREATE TABLE IF NOT EXISTS terminal_workshop (
53
+ terminal_id TEXT NOT NULL,
54
+ product_workshop_id TEXT NOT NULL,
55
+ synced_at TEXT
56
+ );
57
+
58
+ CREATE INDEX IF NOT EXISTS idx_terminal_workshop_terminal ON terminal_workshop (terminal_id);
59
+ CREATE INDEX IF NOT EXISTS idx_terminal_workshop_workshop ON terminal_workshop (product_workshop_id);
60
+
61
+ -- ------------------------------------------------------------------ триггеры грязных чеков
62
+
63
+ -- Тикеты едут наверх в агрегате своего чека (как позиции и платежи), поэтому их правка
64
+ -- помечает грязным сам чек. Зеркало пишет их под sync_suppress — тогда триггеры молчат.
65
+
66
+ CREATE TRIGGER IF NOT EXISTS sync_dirty_kds_tickets_ins AFTER INSERT ON kds_tickets
67
+ WHEN NOT EXISTS (SELECT 1 FROM sync_suppress)
68
+ BEGIN
69
+ INSERT INTO sync_dirty (entity, id, version, dirty_at) VALUES ('order', NEW.order_id, 1, datetime('now'))
70
+ ON CONFLICT (entity, id) DO UPDATE SET version = version + 1, dirty_at = excluded.dirty_at;
71
+ END;
72
+
73
+ CREATE TRIGGER IF NOT EXISTS sync_dirty_kds_tickets_upd AFTER UPDATE ON kds_tickets
74
+ WHEN NOT EXISTS (SELECT 1 FROM sync_suppress)
75
+ BEGIN
76
+ INSERT INTO sync_dirty (entity, id, version, dirty_at) VALUES ('order', NEW.order_id, 1, datetime('now'))
77
+ ON CONFLICT (entity, id) DO UPDATE SET version = version + 1, dirty_at = excluded.dirty_at;
78
+ END;
79
+
80
+ CREATE TRIGGER IF NOT EXISTS sync_dirty_kds_ticket_items_ins AFTER INSERT ON kds_ticket_items
81
+ WHEN NOT EXISTS (SELECT 1 FROM sync_suppress)
82
+ BEGIN
83
+ INSERT INTO sync_dirty (entity, id, version, dirty_at)
84
+ SELECT 'order', t.order_id, 1, datetime('now') FROM kds_tickets t WHERE t.id = NEW.kds_ticket_id
85
+ ON CONFLICT (entity, id) DO UPDATE SET version = version + 1, dirty_at = excluded.dirty_at;
86
+ END;
87
+
88
+ CREATE TRIGGER IF NOT EXISTS sync_dirty_kds_ticket_items_upd AFTER UPDATE ON kds_ticket_items
89
+ WHEN NOT EXISTS (SELECT 1 FROM sync_suppress)
90
+ BEGIN
91
+ INSERT INTO sync_dirty (entity, id, version, dirty_at)
92
+ SELECT 'order', t.order_id, 1, datetime('now') FROM kds_tickets t WHERE t.id = NEW.kds_ticket_id
93
+ ON CONFLICT (entity, id) DO UPDATE SET version = version + 1, dirty_at = excluded.dirty_at;
94
+ END;
@@ -0,0 +1,20 @@
1
+ -- Категории товаров — для отчёта кассы (reports.getReport, разбивка by_category) без облака.
2
+ -- Облако шлёт их в снапшоте давно (SyncService::snapshotTables), но без локальной таблицы
3
+ -- applyTables их пропускал. Только живые строки: надгробий у категорий в снапшоте нет,
4
+ -- deleted_at оставлен ради одинаковых запросов с Postgres.
5
+
6
+ CREATE TABLE IF NOT EXISTS product_categories (
7
+ id TEXT PRIMARY KEY,
8
+ team_id TEXT,
9
+ name TEXT,
10
+ description TEXT,
11
+ slug TEXT,
12
+ parent_id TEXT,
13
+ "order" INTEGER DEFAULT 0,
14
+ color TEXT,
15
+ image TEXT,
16
+ deleted_at TEXT,
17
+ synced_at TEXT
18
+ );
19
+
20
+ CREATE INDEX IF NOT EXISTS idx_product_categories_team ON product_categories (team_id);
@@ -0,0 +1,43 @@
1
+ -- Настройка принтеров без облака (printers.update / delete / syncAddress в ядре).
2
+ --
3
+ -- Правка принтера на кассе помечает его грязным — push везёт строку принтера вместе с его
4
+ -- цехами (printer_workshop) в облако. Снапшот грязный принтер не перезаписывает, пока push
5
+ -- его не доставил. Снапшот и зеркало пишут под sync_suppress — триггеры молчат.
6
+ --
7
+ -- Сущность — 'printer', id — printers.id. Связки printer_workshop своего id не имеют,
8
+ -- поэтому помечают своего принтера.
9
+
10
+ CREATE TRIGGER IF NOT EXISTS sync_dirty_printers_ins AFTER INSERT ON printers
11
+ WHEN NOT EXISTS (SELECT 1 FROM sync_suppress)
12
+ BEGIN
13
+ INSERT INTO sync_dirty (entity, id, version, dirty_at) VALUES ('printer', NEW.id, 1, datetime('now'))
14
+ ON CONFLICT (entity, id) DO UPDATE SET version = version + 1, dirty_at = excluded.dirty_at;
15
+ END;
16
+
17
+ CREATE TRIGGER IF NOT EXISTS sync_dirty_printers_upd AFTER UPDATE ON printers
18
+ WHEN NOT EXISTS (SELECT 1 FROM sync_suppress)
19
+ BEGIN
20
+ INSERT INTO sync_dirty (entity, id, version, dirty_at) VALUES ('printer', NEW.id, 1, datetime('now'))
21
+ ON CONFLICT (entity, id) DO UPDATE SET version = version + 1, dirty_at = excluded.dirty_at;
22
+ END;
23
+
24
+ CREATE TRIGGER IF NOT EXISTS sync_dirty_printer_workshop_ins AFTER INSERT ON printer_workshop
25
+ WHEN NOT EXISTS (SELECT 1 FROM sync_suppress)
26
+ BEGIN
27
+ INSERT INTO sync_dirty (entity, id, version, dirty_at) VALUES ('printer', NEW.printer_id, 1, datetime('now'))
28
+ ON CONFLICT (entity, id) DO UPDATE SET version = version + 1, dirty_at = excluded.dirty_at;
29
+ END;
30
+
31
+ CREATE TRIGGER IF NOT EXISTS sync_dirty_printer_workshop_upd AFTER UPDATE ON printer_workshop
32
+ WHEN NOT EXISTS (SELECT 1 FROM sync_suppress)
33
+ BEGIN
34
+ INSERT INTO sync_dirty (entity, id, version, dirty_at) VALUES ('printer', NEW.printer_id, 1, datetime('now'))
35
+ ON CONFLICT (entity, id) DO UPDATE SET version = version + 1, dirty_at = excluded.dirty_at;
36
+ END;
37
+
38
+ CREATE TRIGGER IF NOT EXISTS sync_dirty_printer_workshop_del AFTER DELETE ON printer_workshop
39
+ WHEN NOT EXISTS (SELECT 1 FROM sync_suppress)
40
+ BEGIN
41
+ INSERT INTO sync_dirty (entity, id, version, dirty_at) VALUES ('printer', OLD.printer_id, 1, datetime('now'))
42
+ ON CONFLICT (entity, id) DO UPDATE SET version = version + 1, dirty_at = excluded.dirty_at;
43
+ END;
@@ -0,0 +1,13 @@
1
+ -- Логотип команды для счёта (orders.printBillReceipt). Laravel читает его из своего Storage
2
+ -- (ImageService::toBase64), у кассы туда доступа нет — копию привозит снапшот ключом team_logo.
3
+ --
4
+ -- Это не таблица справочника: снапшот заменяет справочники целиком, а облако повторно логотип
5
+ -- не шлёт, пока у кассы та же картинка (image_id). Поэтому своя таблица, которую пишет только
6
+ -- snapshot.js::applyTeamLogo.
7
+
8
+ CREATE TABLE IF NOT EXISTS team_logos (
9
+ team_id TEXT PRIMARY KEY,
10
+ image_id TEXT,
11
+ data TEXT,
12
+ updated_at TEXT
13
+ );
@@ -0,0 +1,4 @@
1
+ -- Цех категории: мутатор Product::productWorkshopId берёт его, когда у товара свой цех пуст.
2
+ -- Без колонки касса не отправляла такие позиции на кухню (ни бумага, ни KDS). Снапшот шлёт
3
+ -- product_categories целиком и заполнит колонку при первой же синхронизации.
4
+ ALTER TABLE product_categories ADD COLUMN product_workshop_id TEXT;
package/app/monitor.js ADDED
@@ -0,0 +1,185 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+ const {performance} = require('node:perf_hooks');
4
+
5
+ /**
6
+ * Самочувствие кассы для суперадминки (OFFLINE-FIRST-PLAN §12.8, P2): задержка event loop,
7
+ * память, итоги печати, размер локальной БД и свободное место на диске.
8
+ *
9
+ * В облако уходит внутри heartbeat синка (sync.mode → terminals.sync_status.monitor), поэтому
10
+ * отправляется только там, где синк включён. OFFLINE_MONITOR=false — поле не отправляется,
11
+ * сэмплер не заводится. Счётчики печати копятся всегда: это пара чисел в памяти.
12
+ *
13
+ * Задержка event loop — дрейф собственного таймера, а не perf_hooks.monitorEventLoopDelay:
14
+ * тот после reset() теряет замер, пересекающий сброс, а сброс делает сам heartbeat — то есть
15
+ * блокировки внутри цикла синка выпадали бы из окна систематически.
16
+ */
17
+ const enabled = () => (process.env.OFFLINE_MONITOR || 'true').toLowerCase() !== 'false';
18
+
19
+ const MB = 1024 * 1024;
20
+
21
+ // шаг сэмплера — он же погрешность: блокировку таймер видит заниженной не больше чем на шаг
22
+ const SAMPLE_MS = 20;
23
+ // больше — это сон/гибернация машины, а не блокировка потока
24
+ const SUSPEND_MS = 30000;
25
+ const MAX_SAMPLES = 4096;
26
+
27
+ let sampler = null;
28
+ let expectedAt = 0;
29
+ let windowStartedAt = Date.now();
30
+ const lag = {count: 0, sum: 0, max: 0, samples: []};
31
+
32
+ const print = {
33
+ ok: 0,
34
+ // честная печать (PRINT_HONEST_ACK) без подтверждения от LAN-принтера
35
+ unconfirmed: 0,
36
+ failed: 0,
37
+ errors: {},
38
+ last_ok_at: null,
39
+ last_error: null,
40
+ last_error_at: null,
41
+ };
42
+
43
+ const nowIso = () => new Date().toISOString();
44
+
45
+ const resetLag = () => {
46
+ lag.count = 0;
47
+ lag.sum = 0;
48
+ lag.max = 0;
49
+ lag.samples = [];
50
+ windowStartedAt = Date.now();
51
+ };
52
+
53
+ /** Таймер срабатывает на SAMPLE_MS позже прошлого запуска; всё сверх этого — задержка потока. */
54
+ const sample = () => {
55
+ const now = performance.now();
56
+ const delay = Math.max(0, now - expectedAt);
57
+ expectedAt = now + SAMPLE_MS;
58
+ if (delay > SUSPEND_MS) {
59
+ return;
60
+ }
61
+ lag.count += 1;
62
+ lag.sum += delay;
63
+ lag.max = Math.max(lag.max, delay);
64
+ // окно без heartbeat (облако недоступно) растёт сколько угодно — для p99 держим выборку
65
+ if (lag.samples.length < MAX_SAMPLES) {
66
+ lag.samples.push(delay);
67
+ } else {
68
+ const slot = Math.floor(Math.random() * lag.count);
69
+ if (slot < MAX_SAMPLES) {
70
+ lag.samples[slot] = delay;
71
+ }
72
+ }
73
+ };
74
+
75
+ const ensureSampler = () => {
76
+ if (!sampler) {
77
+ expectedAt = performance.now() + SAMPLE_MS;
78
+ sampler = setInterval(sample, SAMPLE_MS);
79
+ sampler.unref();
80
+ resetLag();
81
+ }
82
+ };
83
+
84
+ const percentile = (values, p) => {
85
+ const sorted = [...values].sort((a, b) => a - b);
86
+ return sorted[Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1)];
87
+ };
88
+
89
+ /** Итог /printers.print (тело ответа терминалу). Моки APP_DEBUG не считаем. */
90
+ const recordPrint = (body) => {
91
+ if (!body || typeof body !== 'object' || body.mock) {
92
+ return;
93
+ }
94
+ if (body.success) {
95
+ if (body.confirmed === false) {
96
+ print.unconfirmed += 1;
97
+ } else {
98
+ print.ok += 1;
99
+ }
100
+ print.last_ok_at = nowIso();
101
+ return;
102
+ }
103
+ const code = typeof body.error === 'string' && body.error ? body.error.slice(0, 40) : 'unknown';
104
+ print.failed += 1;
105
+ print.errors[code] = (print.errors[code] || 0) + 1;
106
+ print.last_error = code;
107
+ print.last_error_at = nowIso();
108
+ };
109
+
110
+ const round = (value, digits = 1) => {
111
+ const factor = 10 ** digits;
112
+ return Math.round(value * factor) / factor;
113
+ };
114
+
115
+ const fileSize = (file) => {
116
+ try {
117
+ return fs.statSync(file).size;
118
+ } catch (e) {
119
+ return 0;
120
+ }
121
+ };
122
+
123
+ /** Размер SQLite вместе с WAL и свободное место на её диске. */
124
+ const disk = (dbFile) => {
125
+ if (!dbFile) {
126
+ return {db_mb: null, disk_free_mb: null};
127
+ }
128
+ const bytes = fileSize(dbFile) + fileSize(`${dbFile}-wal`);
129
+ let free = null;
130
+ if (typeof fs.statfsSync === 'function') {
131
+ try {
132
+ const stats = fs.statfsSync(path.dirname(dbFile));
133
+ free = Math.round((Number(stats.bavail) * Number(stats.bsize)) / MB);
134
+ } catch (e) {
135
+ free = null;
136
+ }
137
+ }
138
+ return {db_mb: round(bytes / MB), disk_free_mb: free};
139
+ };
140
+
141
+ /**
142
+ * Снимок для heartbeat. Задержка event loop — за окно с прошлого снимка (reset: true сбрасывает
143
+ * окно; /local/status смотрит без сброса). null — мониторинг выключен; loop: null — в окне
144
+ * ещё не было замеров.
145
+ */
146
+ const snapshot = ({dbFile = null, reset = true} = {}) => {
147
+ if (!enabled()) {
148
+ return null;
149
+ }
150
+ ensureSampler();
151
+ const memory = process.memoryUsage();
152
+ const loop = lag.count > 0 ? {
153
+ p99_ms: round(percentile(lag.samples, 99)),
154
+ max_ms: round(lag.max),
155
+ mean_ms: round(lag.sum / lag.count),
156
+ window_s: Math.round((Date.now() - windowStartedAt) / 1000),
157
+ } : null;
158
+ if (reset) {
159
+ resetLag();
160
+ }
161
+ return {
162
+ uptime_s: Math.round(process.uptime()),
163
+ rss_mb: Math.round(memory.rss / MB),
164
+ heap_mb: Math.round(memory.heapUsed / MB),
165
+ loop,
166
+ print: {...print, errors: {...print.errors}},
167
+ ...disk(dbFile),
168
+ };
169
+ };
170
+
171
+ /** Запуск сэмплера заранее, чтобы первый heartbeat пришёл уже с окном. */
172
+ const start = () => {
173
+ if (enabled()) {
174
+ ensureSampler();
175
+ }
176
+ };
177
+
178
+ const stop = () => {
179
+ if (sampler) {
180
+ clearInterval(sampler);
181
+ sampler = null;
182
+ }
183
+ };
184
+
185
+ module.exports = {enabled, start, stop, snapshot, recordPrint};