anoderb commited on
Commit
48013ee
·
1 Parent(s): f97c402

feat: implement chatbot service, active shift logic corrections, audit logs, and void transaction tool

Browse files
.gitignore CHANGED
@@ -33,3 +33,6 @@ trace.txt
33
 
34
  # TypeScript build info cache
35
  *.tsbuildinfo
 
 
 
 
33
 
34
  # TypeScript build info cache
35
  *.tsbuildinfo
36
+
37
+ # Exported report files
38
+ src/public/exports/
package-lock.json CHANGED
@@ -9,11 +9,14 @@
9
  "version": "1.0.0",
10
  "dependencies": {
11
  "@prisma/client": "^5.19.0",
 
12
  "bcryptjs": "^2.4.3",
13
  "cors": "^2.8.5",
14
  "dotenv": "^16.4.5",
15
  "express": "^4.21.0",
16
  "jsonwebtoken": "^9.0.2",
 
 
17
  "zod": "^3.23.8"
18
  },
19
  "devDependencies": {
@@ -64,6 +67,28 @@
64
  "@jridgewell/sourcemap-codec": "^1.4.10"
65
  }
66
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  "node_modules/@prisma/client": {
68
  "version": "5.22.0",
69
  "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz",
@@ -126,6 +151,14 @@
126
  "@prisma/debug": "5.22.0"
127
  }
128
  },
 
 
 
 
 
 
 
 
129
  "node_modules/@tsconfig/node10": {
130
  "version": "1.0.12",
131
  "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
@@ -240,11 +273,18 @@
240
  "version": "22.20.0",
241
  "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz",
242
  "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==",
243
- "dev": true,
244
  "dependencies": {
245
  "undici-types": "~6.21.0"
246
  }
247
  },
 
 
 
 
 
 
 
 
248
  "node_modules/@types/qs": {
249
  "version": "6.15.1",
250
  "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
@@ -365,6 +405,25 @@
365
  "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
366
  "dev": true
367
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
368
  "node_modules/bcryptjs": {
369
  "version": "2.4.3",
370
  "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
@@ -427,6 +486,22 @@
427
  "node": ">=8"
428
  }
429
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
430
  "node_modules/buffer-equal-constant-time": {
431
  "version": "1.0.1",
432
  "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
@@ -497,6 +572,14 @@
497
  "fsevents": "~2.3.2"
498
  }
499
  },
 
 
 
 
 
 
 
 
500
  "node_modules/concat-map": {
501
  "version": "0.0.1",
502
  "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -582,6 +665,11 @@
582
  "npm": "1.2.8000 || >= 1.4.16"
583
  }
584
  },
 
 
 
 
 
585
  "node_modules/diff": {
586
  "version": "4.0.4",
587
  "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
@@ -730,6 +818,11 @@
730
  "url": "https://opencollective.com/express"
731
  }
732
  },
 
 
 
 
 
733
  "node_modules/fill-range": {
734
  "version": "7.1.1",
735
  "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -759,6 +852,22 @@
759
  "node": ">= 0.8"
760
  }
761
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
762
  "node_modules/forwarded": {
763
  "version": "0.2.0",
764
  "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -1015,6 +1124,11 @@
1015
  "node": ">=0.12.0"
1016
  }
1017
  },
 
 
 
 
 
1018
  "node_modules/jsonwebtoken": {
1019
  "version": "9.0.3",
1020
  "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@@ -1060,6 +1174,23 @@
1060
  "safe-buffer": "^5.0.1"
1061
  }
1062
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1063
  "node_modules/lodash.includes": {
1064
  "version": "4.3.0",
1065
  "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@@ -1257,6 +1388,40 @@
1257
  "wrappy": "1"
1258
  }
1259
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1260
  "node_modules/parseurl": {
1261
  "version": "1.3.3",
1262
  "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -1285,6 +1450,19 @@
1285
  "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
1286
  "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="
1287
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
1288
  "node_modules/picomatch": {
1289
  "version": "2.3.2",
1290
  "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
@@ -1297,6 +1475,14 @@
1297
  "url": "https://github.com/sponsors/jonschlinkert"
1298
  }
1299
  },
 
 
 
 
 
 
 
 
1300
  "node_modules/prisma": {
1301
  "version": "5.22.0",
1302
  "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
@@ -1398,6 +1584,11 @@
1398
  "url": "https://github.com/sponsors/ljharb"
1399
  }
1400
  },
 
 
 
 
 
1401
  "node_modules/rimraf": {
1402
  "version": "2.7.1",
1403
  "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
@@ -1618,6 +1809,11 @@
1618
  "url": "https://github.com/sponsors/ljharb"
1619
  }
1620
  },
 
 
 
 
 
1621
  "node_modules/to-regex-range": {
1622
  "version": "5.0.1",
1623
  "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -1736,6 +1932,11 @@
1736
  "strip-json-comments": "^2.0.0"
1737
  }
1738
  },
 
 
 
 
 
1739
  "node_modules/type-is": {
1740
  "version": "1.6.18",
1741
  "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@@ -1764,8 +1965,30 @@
1764
  "node_modules/undici-types": {
1765
  "version": "6.21.0",
1766
  "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
1767
- "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
1768
- "dev": true
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1769
  },
1770
  "node_modules/unpipe": {
1771
  "version": "1.0.0",
 
9
  "version": "1.0.0",
10
  "dependencies": {
11
  "@prisma/client": "^5.19.0",
12
+ "@types/pdfkit": "^0.17.6",
13
  "bcryptjs": "^2.4.3",
14
  "cors": "^2.8.5",
15
  "dotenv": "^16.4.5",
16
  "express": "^4.21.0",
17
  "jsonwebtoken": "^9.0.2",
18
+ "openai": "^6.45.0",
19
+ "pdfkit": "^0.19.1",
20
  "zod": "^3.23.8"
21
  },
22
  "devDependencies": {
 
67
  "@jridgewell/sourcemap-codec": "^1.4.10"
68
  }
69
  },
70
+ "node_modules/@noble/ciphers": {
71
+ "version": "1.3.0",
72
+ "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
73
+ "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
74
+ "engines": {
75
+ "node": "^14.21.3 || >=16"
76
+ },
77
+ "funding": {
78
+ "url": "https://paulmillr.com/funding/"
79
+ }
80
+ },
81
+ "node_modules/@noble/hashes": {
82
+ "version": "1.8.0",
83
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
84
+ "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
85
+ "engines": {
86
+ "node": "^14.21.3 || >=16"
87
+ },
88
+ "funding": {
89
+ "url": "https://paulmillr.com/funding/"
90
+ }
91
+ },
92
  "node_modules/@prisma/client": {
93
  "version": "5.22.0",
94
  "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz",
 
151
  "@prisma/debug": "5.22.0"
152
  }
153
  },
154
+ "node_modules/@swc/helpers": {
155
+ "version": "0.5.23",
156
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
157
+ "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
158
+ "dependencies": {
159
+ "tslib": "^2.8.0"
160
+ }
161
+ },
162
  "node_modules/@tsconfig/node10": {
163
  "version": "1.0.12",
164
  "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
 
273
  "version": "22.20.0",
274
  "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz",
275
  "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==",
 
276
  "dependencies": {
277
  "undici-types": "~6.21.0"
278
  }
279
  },
280
+ "node_modules/@types/pdfkit": {
281
+ "version": "0.17.6",
282
+ "resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.6.tgz",
283
+ "integrity": "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==",
284
+ "dependencies": {
285
+ "@types/node": "*"
286
+ }
287
+ },
288
  "node_modules/@types/qs": {
289
  "version": "6.15.1",
290
  "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
 
405
  "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
406
  "dev": true
407
  },
408
+ "node_modules/base64-js": {
409
+ "version": "1.5.1",
410
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
411
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
412
+ "funding": [
413
+ {
414
+ "type": "github",
415
+ "url": "https://github.com/sponsors/feross"
416
+ },
417
+ {
418
+ "type": "patreon",
419
+ "url": "https://www.patreon.com/feross"
420
+ },
421
+ {
422
+ "type": "consulting",
423
+ "url": "https://feross.org/support"
424
+ }
425
+ ]
426
+ },
427
  "node_modules/bcryptjs": {
428
  "version": "2.4.3",
429
  "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
 
486
  "node": ">=8"
487
  }
488
  },
489
+ "node_modules/brotli": {
490
+ "version": "1.3.3",
491
+ "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
492
+ "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
493
+ "dependencies": {
494
+ "base64-js": "^1.1.2"
495
+ }
496
+ },
497
+ "node_modules/browserify-zlib": {
498
+ "version": "0.2.0",
499
+ "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
500
+ "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
501
+ "dependencies": {
502
+ "pako": "~1.0.5"
503
+ }
504
+ },
505
  "node_modules/buffer-equal-constant-time": {
506
  "version": "1.0.1",
507
  "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
 
572
  "fsevents": "~2.3.2"
573
  }
574
  },
575
+ "node_modules/clone": {
576
+ "version": "2.1.2",
577
+ "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
578
+ "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
579
+ "engines": {
580
+ "node": ">=0.8"
581
+ }
582
+ },
583
  "node_modules/concat-map": {
584
  "version": "0.0.1",
585
  "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
 
665
  "npm": "1.2.8000 || >= 1.4.16"
666
  }
667
  },
668
+ "node_modules/dfa": {
669
+ "version": "1.2.0",
670
+ "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
671
+ "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="
672
+ },
673
  "node_modules/diff": {
674
  "version": "4.0.4",
675
  "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
 
818
  "url": "https://opencollective.com/express"
819
  }
820
  },
821
+ "node_modules/fast-deep-equal": {
822
+ "version": "3.1.3",
823
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
824
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
825
+ },
826
  "node_modules/fill-range": {
827
  "version": "7.1.1",
828
  "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
 
852
  "node": ">= 0.8"
853
  }
854
  },
855
+ "node_modules/fontkit": {
856
+ "version": "2.0.4",
857
+ "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
858
+ "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
859
+ "dependencies": {
860
+ "@swc/helpers": "^0.5.12",
861
+ "brotli": "^1.3.2",
862
+ "clone": "^2.1.2",
863
+ "dfa": "^1.2.0",
864
+ "fast-deep-equal": "^3.1.3",
865
+ "restructure": "^3.0.0",
866
+ "tiny-inflate": "^1.0.3",
867
+ "unicode-properties": "^1.4.0",
868
+ "unicode-trie": "^2.0.0"
869
+ }
870
+ },
871
  "node_modules/forwarded": {
872
  "version": "0.2.0",
873
  "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
 
1124
  "node": ">=0.12.0"
1125
  }
1126
  },
1127
+ "node_modules/js-md5": {
1128
+ "version": "0.8.3",
1129
+ "resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz",
1130
+ "integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ=="
1131
+ },
1132
  "node_modules/jsonwebtoken": {
1133
  "version": "9.0.3",
1134
  "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
 
1174
  "safe-buffer": "^5.0.1"
1175
  }
1176
  },
1177
+ "node_modules/linebreak": {
1178
+ "version": "1.1.0",
1179
+ "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
1180
+ "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
1181
+ "dependencies": {
1182
+ "base64-js": "0.0.8",
1183
+ "unicode-trie": "^2.0.0"
1184
+ }
1185
+ },
1186
+ "node_modules/linebreak/node_modules/base64-js": {
1187
+ "version": "0.0.8",
1188
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
1189
+ "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
1190
+ "engines": {
1191
+ "node": ">= 0.4"
1192
+ }
1193
+ },
1194
  "node_modules/lodash.includes": {
1195
  "version": "4.3.0",
1196
  "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
 
1388
  "wrappy": "1"
1389
  }
1390
  },
1391
+ "node_modules/openai": {
1392
+ "version": "6.45.0",
1393
+ "resolved": "https://registry.npmjs.org/openai/-/openai-6.45.0.tgz",
1394
+ "integrity": "sha512-5DQVNErssk0afNpTTHUm/qZPU4iKR9OYdNid8Ib4puq4gHNNvGWZht2zY4h9a8JMF949Ik6m8gQutllVPbjdnw==",
1395
+ "peerDependencies": {
1396
+ "@aws-sdk/credential-provider-node": ">=3.972.0 <4",
1397
+ "@smithy/hash-node": ">=4.3.0 <5",
1398
+ "@smithy/signature-v4": ">=5.4.0 <6",
1399
+ "ws": "^8.18.0",
1400
+ "zod": "^3.25 || ^4.0"
1401
+ },
1402
+ "peerDependenciesMeta": {
1403
+ "@aws-sdk/credential-provider-node": {
1404
+ "optional": true
1405
+ },
1406
+ "@smithy/hash-node": {
1407
+ "optional": true
1408
+ },
1409
+ "@smithy/signature-v4": {
1410
+ "optional": true
1411
+ },
1412
+ "ws": {
1413
+ "optional": true
1414
+ },
1415
+ "zod": {
1416
+ "optional": true
1417
+ }
1418
+ }
1419
+ },
1420
+ "node_modules/pako": {
1421
+ "version": "1.0.11",
1422
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
1423
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
1424
+ },
1425
  "node_modules/parseurl": {
1426
  "version": "1.3.3",
1427
  "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
 
1450
  "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
1451
  "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="
1452
  },
1453
+ "node_modules/pdfkit": {
1454
+ "version": "0.19.1",
1455
+ "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.19.1.tgz",
1456
+ "integrity": "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA==",
1457
+ "dependencies": {
1458
+ "@noble/ciphers": "^1.0.0",
1459
+ "@noble/hashes": "^1.6.0",
1460
+ "fontkit": "^2.0.4",
1461
+ "js-md5": "^0.8.3",
1462
+ "linebreak": "^1.1.0",
1463
+ "png-js": "^1.1.0"
1464
+ }
1465
+ },
1466
  "node_modules/picomatch": {
1467
  "version": "2.3.2",
1468
  "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
 
1475
  "url": "https://github.com/sponsors/jonschlinkert"
1476
  }
1477
  },
1478
+ "node_modules/png-js": {
1479
+ "version": "1.1.0",
1480
+ "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz",
1481
+ "integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==",
1482
+ "dependencies": {
1483
+ "browserify-zlib": "^0.2.0"
1484
+ }
1485
+ },
1486
  "node_modules/prisma": {
1487
  "version": "5.22.0",
1488
  "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
 
1584
  "url": "https://github.com/sponsors/ljharb"
1585
  }
1586
  },
1587
+ "node_modules/restructure": {
1588
+ "version": "3.0.2",
1589
+ "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
1590
+ "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="
1591
+ },
1592
  "node_modules/rimraf": {
1593
  "version": "2.7.1",
1594
  "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
 
1809
  "url": "https://github.com/sponsors/ljharb"
1810
  }
1811
  },
1812
+ "node_modules/tiny-inflate": {
1813
+ "version": "1.0.3",
1814
+ "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
1815
+ "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="
1816
+ },
1817
  "node_modules/to-regex-range": {
1818
  "version": "5.0.1",
1819
  "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
 
1932
  "strip-json-comments": "^2.0.0"
1933
  }
1934
  },
1935
+ "node_modules/tslib": {
1936
+ "version": "2.8.1",
1937
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
1938
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
1939
+ },
1940
  "node_modules/type-is": {
1941
  "version": "1.6.18",
1942
  "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
 
1965
  "node_modules/undici-types": {
1966
  "version": "6.21.0",
1967
  "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
1968
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="
1969
+ },
1970
+ "node_modules/unicode-properties": {
1971
+ "version": "1.4.1",
1972
+ "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
1973
+ "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
1974
+ "dependencies": {
1975
+ "base64-js": "^1.3.0",
1976
+ "unicode-trie": "^2.0.0"
1977
+ }
1978
+ },
1979
+ "node_modules/unicode-trie": {
1980
+ "version": "2.0.0",
1981
+ "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
1982
+ "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
1983
+ "dependencies": {
1984
+ "pako": "^0.2.5",
1985
+ "tiny-inflate": "^1.0.0"
1986
+ }
1987
+ },
1988
+ "node_modules/unicode-trie/node_modules/pako": {
1989
+ "version": "0.2.9",
1990
+ "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
1991
+ "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="
1992
  },
1993
  "node_modules/unpipe": {
1994
  "version": "1.0.0",
package.json CHANGED
@@ -12,11 +12,14 @@
12
  },
13
  "dependencies": {
14
  "@prisma/client": "^5.19.0",
 
15
  "bcryptjs": "^2.4.3",
16
  "cors": "^2.8.5",
17
  "dotenv": "^16.4.5",
18
  "express": "^4.21.0",
19
  "jsonwebtoken": "^9.0.2",
 
 
20
  "zod": "^3.23.8"
21
  },
22
  "devDependencies": {
 
12
  },
13
  "dependencies": {
14
  "@prisma/client": "^5.19.0",
15
+ "@types/pdfkit": "^0.17.6",
16
  "bcryptjs": "^2.4.3",
17
  "cors": "^2.8.5",
18
  "dotenv": "^16.4.5",
19
  "express": "^4.21.0",
20
  "jsonwebtoken": "^9.0.2",
21
+ "openai": "^6.45.0",
22
+ "pdfkit": "^0.19.1",
23
  "zod": "^3.23.8"
24
  },
25
  "devDependencies": {
src/app.ts CHANGED
@@ -1,14 +1,23 @@
1
  import express, { Application, Request, Response } from 'express';
2
  import cors from 'cors';
 
 
3
  import router from './routes';
4
  import { errorHandler } from './middlewares/error.middleware';
5
 
 
 
 
 
 
 
6
  const app: Application = express();
7
 
8
  // Global Middleware
9
  app.use(cors());
10
  app.use(express.json({ limit: '50mb' })); // Allow larger payloads (useful for base64 images)
11
  app.use(express.urlencoded({ extended: true, limit: '50mb' }));
 
12
 
13
  // Routing API
14
  app.use('/api', router);
 
1
  import express, { Application, Request, Response } from 'express';
2
  import cors from 'cors';
3
+ import path from 'path';
4
+ import fs from 'fs';
5
  import router from './routes';
6
  import { errorHandler } from './middlewares/error.middleware';
7
 
8
+ // Pastikan direktori export ada
9
+ const exportsDir = path.join(__dirname, '../public/exports');
10
+ if (!fs.existsSync(exportsDir)) {
11
+ fs.mkdirSync(exportsDir, { recursive: true });
12
+ }
13
+
14
  const app: Application = express();
15
 
16
  // Global Middleware
17
  app.use(cors());
18
  app.use(express.json({ limit: '50mb' })); // Allow larger payloads (useful for base64 images)
19
  app.use(express.urlencoded({ extended: true, limit: '50mb' }));
20
+ app.use('/exports', express.static(path.join(__dirname, '../public/exports')));
21
 
22
  // Routing API
23
  app.use('/api', router);
src/controllers/bon.controller.ts CHANGED
@@ -2,6 +2,7 @@ import { Request, Response, NextFunction } from 'express';
2
  import { BonService } from '../services/bon.service';
3
  import { sukses } from '../utils/response';
4
  import { AuthenticatedRequest } from '../middlewares/auth.middleware';
 
5
 
6
  export class BonController {
7
  static async list(req: Request, res: Response, next: NextFunction) {
@@ -60,4 +61,58 @@ export class BonController {
60
  next(error);
61
  }
62
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  }
 
2
  import { BonService } from '../services/bon.service';
3
  import { sukses } from '../utils/response';
4
  import { AuthenticatedRequest } from '../middlewares/auth.middleware';
5
+ import prisma from '../utils/db';
6
 
7
  export class BonController {
8
  static async list(req: Request, res: Response, next: NextFunction) {
 
61
  next(error);
62
  }
63
  }
64
+
65
+ static async listInstallments(req: Request, res: Response, next: NextFunction) {
66
+ try {
67
+ const skip = parseInt(req.query.skip as string) || 0;
68
+ const limit = parseInt(req.query.limit as string) || 20;
69
+
70
+ const installments = await prisma.bon_cicilan.findMany({
71
+ orderBy: { created_at: 'desc' },
72
+ skip,
73
+ take: limit,
74
+ include: {
75
+ bon: {
76
+ include: {
77
+ member: true,
78
+ },
79
+ },
80
+ },
81
+ });
82
+
83
+ const formatted = installments.map((ins) => ({
84
+ tanggal: ins.created_at.toISOString().replace('T', ' ').slice(0, 16),
85
+ nama: ins.bon.member.nama,
86
+ nominal: Number(ins.nominal),
87
+ metode: ins.metode,
88
+ }));
89
+
90
+ return sukses(res, formatted, 'Daftar cicilan berhasil dimuat');
91
+ } catch (error) {
92
+ next(error);
93
+ }
94
+ }
95
+
96
+ static async payByMemberId(req: AuthenticatedRequest, res: Response, next: NextFunction) {
97
+ try {
98
+ const memberId = parseInt(req.params.memberId);
99
+ const actorId = req.user.id;
100
+ const ip = (req.ip || req.socket.remoteAddress || 'unknown').replace('::ffff:', '');
101
+ const userAgent = (req.headers['user-agent'] || 'unknown') as string;
102
+
103
+ const { nominal, metode } = req.body;
104
+ const bon = await BonService.bayarCicilanByMemberId({
105
+ memberId,
106
+ nominal,
107
+ metode,
108
+ actorId,
109
+ ip,
110
+ userAgent,
111
+ });
112
+
113
+ return sukses(res, bon, 'Cicilan kasbon berhasil dibayarkan');
114
+ } catch (error) {
115
+ next(error);
116
+ }
117
+ }
118
  }
src/controllers/chatbot.controller.ts ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * chatbot.controller.ts
3
+ * Endpoint handlers untuk chatbot API.
4
+ */
5
+
6
+ import { Response, NextFunction } from 'express';
7
+ import { ChatbotService } from '../services/chatbot.service';
8
+ import { sukses, gagal } from '../utils/response';
9
+ import { AuthenticatedRequest } from '../middlewares/auth.middleware';
10
+
11
+ export class ChatbotController {
12
+ /**
13
+ * POST /api/chatbot/pesan
14
+ * Kirim pesan ke chatbot dan dapatkan jawaban.
15
+ */
16
+ static async kirimPesan(req: AuthenticatedRequest, res: Response, next: NextFunction) {
17
+ try {
18
+ const { pesan } = req.body;
19
+
20
+ if (!pesan || typeof pesan !== 'string' || pesan.trim().length === 0) {
21
+ return gagal(res, 400, 'Pesan tidak boleh kosong');
22
+ }
23
+
24
+ if (pesan.length > 1000) {
25
+ return gagal(res, 400, 'Pesan terlalu panjang (maks 1000 karakter)');
26
+ }
27
+
28
+ const user = req.user;
29
+ const ip = (req.ip || req.socket.remoteAddress || 'unknown').replace('::ffff:', '');
30
+ const userAgent = (req.headers['user-agent'] || 'unknown') as string;
31
+
32
+ const result = await ChatbotService.kirimPesan(
33
+ { id: user.id, nama: user.nama, role: user.role },
34
+ pesan.trim(),
35
+ ip,
36
+ userAgent
37
+ );
38
+
39
+ return sukses(res, result, 'Pesan berhasil diproses');
40
+ } catch (error) {
41
+ next(error);
42
+ }
43
+ }
44
+
45
+ /**
46
+ * POST /api/chatbot/konfirmasi
47
+ * Konfirmasi atau batalkan aksi pending.
48
+ */
49
+ static async konfirmasiAksi(req: AuthenticatedRequest, res: Response, next: NextFunction) {
50
+ try {
51
+ const { aksiId, setuju } = req.body;
52
+
53
+ if (!aksiId || typeof aksiId !== 'string') {
54
+ return gagal(res, 400, 'ID aksi tidak valid');
55
+ }
56
+
57
+ if (typeof setuju !== 'boolean') {
58
+ return gagal(res, 400, 'Parameter "setuju" harus boolean (true/false)');
59
+ }
60
+
61
+ const user = req.user;
62
+ const ip = (req.ip || req.socket.remoteAddress || 'unknown').replace('::ffff:', '');
63
+ const userAgent = (req.headers['user-agent'] || 'unknown') as string;
64
+
65
+ const result = await ChatbotService.konfirmasiAksi(
66
+ aksiId,
67
+ setuju,
68
+ { id: user.id, nama: user.nama, role: user.role },
69
+ ip,
70
+ userAgent
71
+ );
72
+
73
+ return sukses(res, result, setuju ? 'Aksi berhasil dikonfirmasi' : 'Aksi dibatalkan');
74
+ } catch (error) {
75
+ next(error);
76
+ }
77
+ }
78
+
79
+ /**
80
+ * DELETE /api/chatbot/riwayat
81
+ * Hapus riwayat chat session user.
82
+ */
83
+ static async hapusRiwayat(req: AuthenticatedRequest, res: Response, next: NextFunction) {
84
+ try {
85
+ const user = req.user;
86
+ ChatbotService.hapusRiwayat(user.id);
87
+ return sukses(res, null, 'Riwayat chat berhasil dihapus');
88
+ } catch (error) {
89
+ next(error);
90
+ }
91
+ }
92
+ }
src/controllers/diskon.controller.ts ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * diskon.controller.ts
3
+ * Controller untuk mengelola data diskon & promosi (CRUD).
4
+ */
5
+
6
+ import { Request, Response, NextFunction } from 'express';
7
+ import prisma from '../utils/db';
8
+ import { sukses, gagal } from '../utils/response';
9
+ import { AuthenticatedRequest } from '../middlewares/auth.middleware';
10
+ import { catatAudit } from '../utils/audit';
11
+
12
+ export class DiskonController {
13
+ static async list(req: Request, res: Response, next: NextFunction) {
14
+ try {
15
+ const skip = parseInt(req.query.skip as string) || 0;
16
+ const limit = parseInt(req.query.limit as string) || 100;
17
+ const pencarian = req.query.pencarian as string || undefined;
18
+
19
+ const where: any = {};
20
+ if (pencarian) {
21
+ where.nama = {
22
+ contains: pencarian,
23
+ };
24
+ }
25
+
26
+ const total = await prisma.diskon.count({ where });
27
+ const promos = await prisma.diskon.findMany({
28
+ where,
29
+ skip,
30
+ take: limit,
31
+ orderBy: { created_at: 'desc' },
32
+ });
33
+
34
+ return sukses(res, promos, 'Daftar promosi berhasil dimuat', { total });
35
+ } catch (error) {
36
+ next(error);
37
+ }
38
+ }
39
+
40
+ static async get(req: Request, res: Response, next: NextFunction) {
41
+ try {
42
+ const id = parseInt(req.params.id);
43
+ const promo = await prisma.diskon.findUnique({
44
+ where: { id },
45
+ });
46
+
47
+ if (!promo) {
48
+ return gagal(res, 404, 'Promosi tidak ditemukan');
49
+ }
50
+
51
+ return sukses(res, promo, 'Detail promosi berhasil dimuat');
52
+ } catch (error) {
53
+ next(error);
54
+ }
55
+ }
56
+
57
+ static async create(req: AuthenticatedRequest, res: Response, next: NextFunction) {
58
+ try {
59
+ const actorId = req.user.id;
60
+ const ip = (req.ip || req.socket.remoteAddress || 'unknown').replace('::ffff:', '');
61
+ const userAgent = (req.headers['user-agent'] || 'unknown') as string;
62
+
63
+ const {
64
+ nama,
65
+ tipe,
66
+ nilai,
67
+ min_belanja,
68
+ produk_ids,
69
+ member_only,
70
+ tgl_mulai,
71
+ tgl_selesai,
72
+ jam_mulai,
73
+ jam_selesai,
74
+ is_aktif,
75
+ } = req.body;
76
+
77
+ const promo = await prisma.diskon.create({
78
+ data: {
79
+ nama,
80
+ tipe,
81
+ nilai: Number(nilai),
82
+ min_belanja: min_belanja !== undefined ? Number(min_belanja) : 0,
83
+ produk_ids: produk_ids || null,
84
+ member_only: member_only !== undefined ? Number(member_only) : 0,
85
+ tgl_mulai: new Date(tgl_mulai),
86
+ tgl_selesai: new Date(tgl_selesai),
87
+ jam_mulai: jam_mulai ? new Date(`1970-01-01T${jam_mulai}`) : null,
88
+ jam_selesai: jam_selesai ? new Date(`1970-01-01T${jam_selesai}`) : null,
89
+ is_aktif: is_aktif !== undefined ? Number(is_aktif) : 1,
90
+ },
91
+ });
92
+
93
+ await catatAudit({
94
+ userId: actorId,
95
+ aksi: 'create',
96
+ entitas: 'diskon',
97
+ entitasId: promo.id,
98
+ dataBaru: { nama: promo.nama, tipe: promo.tipe, nilai: Number(promo.nilai) },
99
+ ipAddress: ip,
100
+ userAgent,
101
+ });
102
+
103
+ return sukses(res, promo, 'Promosi berhasil dibuat');
104
+ } catch (error) {
105
+ next(error);
106
+ }
107
+ }
108
+
109
+ static async update(req: AuthenticatedRequest, res: Response, next: NextFunction) {
110
+ try {
111
+ const id = parseInt(req.params.id);
112
+ const actorId = req.user.id;
113
+ const ip = (req.ip || req.socket.remoteAddress || 'unknown').replace('::ffff:', '');
114
+ const userAgent = (req.headers['user-agent'] || 'unknown') as string;
115
+
116
+ const existing = await prisma.diskon.findUnique({ where: { id } });
117
+ if (!existing) {
118
+ return gagal(res, 404, 'Promosi tidak ditemukan');
119
+ }
120
+
121
+ const {
122
+ nama,
123
+ tipe,
124
+ nilai,
125
+ min_belanja,
126
+ produk_ids,
127
+ member_only,
128
+ tgl_mulai,
129
+ tgl_selesai,
130
+ jam_mulai,
131
+ jam_selesai,
132
+ is_aktif,
133
+ } = req.body;
134
+
135
+ const promo = await prisma.diskon.update({
136
+ where: { id },
137
+ data: {
138
+ nama: nama !== undefined ? nama : undefined,
139
+ tipe: tipe !== undefined ? tipe : undefined,
140
+ nilai: nilai !== undefined ? Number(nilai) : undefined,
141
+ min_belanja: min_belanja !== undefined ? Number(min_belanja) : undefined,
142
+ produk_ids: produk_ids !== undefined ? produk_ids : undefined,
143
+ member_only: member_only !== undefined ? Number(member_only) : undefined,
144
+ tgl_mulai: tgl_mulai !== undefined ? new Date(tgl_mulai) : undefined,
145
+ tgl_selesai: tgl_selesai !== undefined ? new Date(tgl_selesai) : undefined,
146
+ jam_mulai: jam_mulai !== undefined ? (jam_mulai ? new Date(`1970-01-01T${jam_mulai}`) : null) : undefined,
147
+ jam_selesai: jam_selesai !== undefined ? (jam_selesai ? new Date(`1970-01-01T${jam_selesai}`) : null) : undefined,
148
+ is_aktif: is_aktif !== undefined ? Number(is_aktif) : undefined,
149
+ },
150
+ });
151
+
152
+ await catatAudit({
153
+ userId: actorId,
154
+ aksi: 'update',
155
+ entitas: 'diskon',
156
+ entitasId: promo.id,
157
+ dataLama: { nama: existing.nama, tipe: existing.tipe, nilai: Number(existing.nilai) },
158
+ dataBaru: { nama: promo.nama, tipe: promo.tipe, nilai: Number(promo.nilai) },
159
+ ipAddress: ip,
160
+ userAgent,
161
+ });
162
+
163
+ return sukses(res, promo, 'Promosi berhasil diperbarui');
164
+ } catch (error) {
165
+ next(error);
166
+ }
167
+ }
168
+
169
+ static async delete(req: AuthenticatedRequest, res: Response, next: NextFunction) {
170
+ try {
171
+ const id = parseInt(req.params.id);
172
+ const actorId = req.user.id;
173
+ const ip = (req.ip || req.socket.remoteAddress || 'unknown').replace('::ffff:', '');
174
+ const userAgent = (req.headers['user-agent'] || 'unknown') as string;
175
+
176
+ const existing = await prisma.diskon.findUnique({ where: { id } });
177
+ if (!existing) {
178
+ return gagal(res, 404, 'Promosi tidak ditemukan');
179
+ }
180
+
181
+ await prisma.diskon.delete({ where: { id } });
182
+
183
+ await catatAudit({
184
+ userId: actorId,
185
+ aksi: 'delete',
186
+ entitas: 'diskon',
187
+ entitasId: id,
188
+ dataLama: { nama: existing.nama, tipe: existing.tipe, nilai: Number(existing.nilai) },
189
+ dataBaru: { deleted: true },
190
+ ipAddress: ip,
191
+ userAgent,
192
+ });
193
+
194
+ return sukses(res, null, 'Promosi berhasil dihapus');
195
+ } catch (error) {
196
+ next(error);
197
+ }
198
+ }
199
+ }
src/controllers/ops.controller.ts CHANGED
@@ -80,6 +80,33 @@ export class OpsController {
80
  }
81
  }
82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  // Settings/Pengaturan
84
  static async getSettings(req: Request, res: Response, next: NextFunction) {
85
  try {
 
80
  }
81
  }
82
 
83
+ static async eksporLaporan(req: AuthenticatedRequest, res: Response, next: NextFunction) {
84
+ try {
85
+ const jenis = req.query.jenis as any;
86
+ const format = req.query.format as any;
87
+ const tglMulai = new Date(req.query.tgl_mulai as string);
88
+ const tglSelesai = new Date(req.query.tgl_selesai as string);
89
+
90
+ if (!['penjualan', 'laba-rugi', 'mutasi-stok'].includes(jenis)) {
91
+ throw new Error('Jenis laporan tidak valid');
92
+ }
93
+ if (!['csv', 'excel', 'pdf'].includes(format)) {
94
+ throw new Error('Format laporan tidak valid. Harus csv, excel, atau pdf');
95
+ }
96
+
97
+ const fileInfo = await OpsService.eksporLaporan({
98
+ jenis,
99
+ format,
100
+ tglMulai,
101
+ tglSelesai,
102
+ });
103
+
104
+ return sukses(res, fileInfo, 'Laporan berhasil diekspor');
105
+ } catch (error: any) {
106
+ res.status(400).json({ sukses: false, pesan: error.message || 'Gagal mengekspor laporan' });
107
+ }
108
+ }
109
+
110
  // Settings/Pengaturan
111
  static async getSettings(req: Request, res: Response, next: NextFunction) {
112
  try {
src/routes/bon.route.ts CHANGED
@@ -6,11 +6,13 @@ import { bayarBonSchema } from '../schemas/bon.schema';
6
 
7
  const bonRouter = Router();
8
 
9
- // Rute Publik
 
10
  bonRouter.get('/', BonController.list);
11
  bonRouter.get('/:id', BonController.get);
12
 
13
  // Rute Terproteksi Token
 
14
  bonRouter.post('/:id/bayar', authenticate, validate(bayarBonSchema), BonController.payInstallment);
15
  bonRouter.post('/:id/reminder', authenticate, BonController.sendDebtReminder);
16
 
 
6
 
7
  const bonRouter = Router();
8
 
9
+ // Rute Publik/Dinamis
10
+ bonRouter.get('/cicilan/terakhir', authenticate, BonController.listInstallments);
11
  bonRouter.get('/', BonController.list);
12
  bonRouter.get('/:id', BonController.get);
13
 
14
  // Rute Terproteksi Token
15
+ bonRouter.post('/member/:memberId/bayar', authenticate, validate(bayarBonSchema), BonController.payByMemberId);
16
  bonRouter.post('/:id/bayar', authenticate, validate(bayarBonSchema), BonController.payInstallment);
17
  bonRouter.post('/:id/reminder', authenticate, BonController.sendDebtReminder);
18
 
src/routes/chatbot.route.ts ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * chatbot.route.ts
3
+ * Route definitions untuk chatbot API.
4
+ * Semua endpoint memerlukan autentikasi (login).
5
+ */
6
+
7
+ import { Router } from 'express';
8
+ import { ChatbotController } from '../controllers/chatbot.controller';
9
+ import { authenticate } from '../middlewares/auth.middleware';
10
+
11
+ const router = Router();
12
+
13
+ // Semua route chatbot butuh autentikasi
14
+ router.use(authenticate);
15
+
16
+ // POST /api/chatbot/pesan — Kirim pesan ke chatbot
17
+ router.post('/pesan', ChatbotController.kirimPesan);
18
+
19
+ // POST /api/chatbot/konfirmasi — Konfirmasi/batalkan aksi pending
20
+ router.post('/konfirmasi', ChatbotController.konfirmasiAksi);
21
+
22
+ // DELETE /api/chatbot/riwayat — Hapus riwayat chat
23
+ router.delete('/riwayat', ChatbotController.hapusRiwayat);
24
+
25
+ export default router;
src/routes/diskon.route.ts ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * diskon.route.ts
3
+ * Route definitions untuk diskon API.
4
+ */
5
+
6
+ import { Router } from 'express';
7
+ import { DiskonController } from '../controllers/diskon.controller';
8
+ import { authenticate } from '../middlewares/auth.middleware';
9
+
10
+ const router = Router();
11
+
12
+ // Rute Publik (bisa diakses kasir/member)
13
+ router.get('/', authenticate, DiskonController.list);
14
+ router.get('/:id', authenticate, DiskonController.get);
15
+
16
+ // Rute Admin (hanya admin yang bisa mengubah data diskon)
17
+ router.post('/', authenticate, DiskonController.create);
18
+ router.put('/:id', authenticate, DiskonController.update);
19
+ router.delete('/:id', authenticate, DiskonController.delete);
20
+
21
+ export default router;
src/routes/index.ts CHANGED
@@ -10,6 +10,8 @@ import stokRouter from './stok.route';
10
  import bonRouter from './bon.route';
11
  import transaksiRouter from './transaksi.route';
12
  import opsRouter from './ops.route';
 
 
13
 
14
  const router = Router();
15
 
@@ -23,6 +25,8 @@ router.use('/produk', produkRouter);
23
  router.use('/stok', stokRouter);
24
  router.use('/bon', bonRouter);
25
  router.use('/transaksi', transaksiRouter);
 
 
26
  router.use('/', opsRouter);
27
 
28
  export default router;
 
10
  import bonRouter from './bon.route';
11
  import transaksiRouter from './transaksi.route';
12
  import opsRouter from './ops.route';
13
+ import chatbotRouter from './chatbot.route';
14
+ import diskonRouter from './diskon.route';
15
 
16
  const router = Router();
17
 
 
25
  router.use('/stok', stokRouter);
26
  router.use('/bon', bonRouter);
27
  router.use('/transaksi', transaksiRouter);
28
+ router.use('/chatbot', chatbotRouter);
29
+ router.use('/diskon', diskonRouter);
30
  router.use('/', opsRouter);
31
 
32
  export default router;
src/routes/ops.route.ts CHANGED
@@ -15,6 +15,7 @@ opsRouter.post('/shift/tutup', authenticate, validate(tutupShiftSchema), OpsCont
15
  opsRouter.get('/dashboard/summary', authenticate, OpsController.getDashboardSummary);
16
  opsRouter.get('/laporan/penjualan', authenticate, validate(laporanQuerySchema), OpsController.getSalesReport);
17
  opsRouter.get('/laporan/laba', authenticate, validate(laporanQuerySchema), OpsController.getProfitLossReport);
 
18
 
19
  // ================= SETTINGS =================
20
  opsRouter.get('/pengaturan', OpsController.getSettings);
 
15
  opsRouter.get('/dashboard/summary', authenticate, OpsController.getDashboardSummary);
16
  opsRouter.get('/laporan/penjualan', authenticate, validate(laporanQuerySchema), OpsController.getSalesReport);
17
  opsRouter.get('/laporan/laba', authenticate, validate(laporanQuerySchema), OpsController.getProfitLossReport);
18
+ opsRouter.get('/laporan/ekspor', authenticate, OpsController.eksporLaporan);
19
 
20
  // ================= SETTINGS =================
21
  opsRouter.get('/pengaturan', OpsController.getSettings);
src/services/bon.service.ts CHANGED
@@ -185,4 +185,39 @@ export class BonService {
185
 
186
  return waLink;
187
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  }
 
185
 
186
  return waLink;
187
  }
188
+
189
+ static async bayarCicilanByMemberId(params: {
190
+ memberId: number;
191
+ nominal: number;
192
+ metode: 'tunai' | 'transfer';
193
+ actorId: number;
194
+ ip: string;
195
+ userAgent: string;
196
+ }) {
197
+ const { memberId, nominal, metode, actorId, ip, userAgent } = params;
198
+
199
+ // Cari bon teraktif milik member
200
+ const bon = await prisma.bon.findFirst({
201
+ where: {
202
+ member_id: memberId,
203
+ status: 'aktif',
204
+ },
205
+ orderBy: {
206
+ created_at: 'asc', // bayar dari bon paling lama terutang
207
+ },
208
+ });
209
+
210
+ if (!bon) {
211
+ throw new APIError('Tidak ada pencatatan kasbon aktif untuk member ini.', 404);
212
+ }
213
+
214
+ return await this.bayarCicilan({
215
+ bonId: bon.id,
216
+ nominal,
217
+ metode,
218
+ actorId,
219
+ ip,
220
+ userAgent,
221
+ });
222
+ }
223
  }
src/services/chatbot.service.ts ADDED
@@ -0,0 +1,406 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * chatbot.service.ts
3
+ * Logic utama chatbot — mengelola conversation, tool execution, dan confirmation flow.
4
+ */
5
+
6
+ import { chatCompletion, type PesanChat } from '../utils/ai-client';
7
+ import { bangunSystemPrompt } from '../utils/chatbot-context';
8
+ import { TOOL_HANDLERS, getToolsForRole } from '../utils/chatbot-tools';
9
+
10
+ // ═══════════════════════════════════
11
+ // TYPES
12
+ // ═══════════════════════════════════
13
+
14
+ interface UserInfo {
15
+ id: number;
16
+ nama: string;
17
+ role: string;
18
+ }
19
+
20
+ interface PendingAction {
21
+ toolName: string;
22
+ params: any;
23
+ userId: number;
24
+ preview: string;
25
+ createdAt: Date;
26
+ }
27
+
28
+ interface ChatbotResponse {
29
+ balasan: string;
30
+ konfirmasi?: {
31
+ aksiId: string;
32
+ preview: string;
33
+ toolName: string;
34
+ };
35
+ }
36
+
37
+ // ═══════════════════════════════════
38
+ // IN-MEMORY STORES
39
+ // ═══════════════════════════════════
40
+
41
+ // Conversation history per user (max 20 messages)
42
+ const conversationStore = new Map<number, PesanChat[]>();
43
+ const MAX_HISTORY = 20;
44
+
45
+ // Pending confirmations per user
46
+ const pendingActions = new Map<string, PendingAction>();
47
+
48
+ // ═══════════════════════════════════
49
+ // HELPERS
50
+ // ═══════════════════════════════════
51
+
52
+ function getConversation(userId: number): PesanChat[] {
53
+ return conversationStore.get(userId) || [];
54
+ }
55
+
56
+ function addToConversation(userId: number, message: PesanChat) {
57
+ const history = getConversation(userId);
58
+ history.push(message);
59
+ // Trim to max history (keep system prompt if exists)
60
+ if (history.length > MAX_HISTORY) {
61
+ history.splice(0, history.length - MAX_HISTORY);
62
+ }
63
+ conversationStore.set(userId, history);
64
+ }
65
+
66
+ function generateAksiId(): string {
67
+ return `aksi_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
68
+ }
69
+
70
+ // ═══════════════════════════════════
71
+ // MAIN SERVICE
72
+ // ═══════════════════════════════════
73
+
74
+ export class ChatbotService {
75
+ /**
76
+ * Proses pesan dari user dan dapatkan jawaban AI.
77
+ */
78
+ static async kirimPesan(
79
+ user: UserInfo,
80
+ pesan: string,
81
+ ip: string,
82
+ userAgent: string
83
+ ): Promise<ChatbotResponse> {
84
+ // 1. Bangun system prompt dengan konteks toko
85
+ const systemPrompt = await bangunSystemPrompt(user.nama, user.role);
86
+
87
+ // 2. Siapkan messages
88
+ const history = getConversation(user.id);
89
+
90
+ const messages: PesanChat[] = [
91
+ { role: 'system', content: systemPrompt },
92
+ ...history,
93
+ { role: 'user', content: pesan },
94
+ ];
95
+
96
+ // 3. Simpan pesan user ke history
97
+ addToConversation(user.id, { role: 'user', content: pesan });
98
+
99
+ // 4. Ambil tools berdasarkan role
100
+ const tools = getToolsForRole(user.role);
101
+
102
+ // 5. Kirim ke AI
103
+ try {
104
+ const result = await chatCompletion(messages, tools);
105
+
106
+ // 6. Jika AI mau panggil tool
107
+ if (result.tool_calls && result.tool_calls.length > 0) {
108
+ return await this.handleToolCalls(result, messages, user, ip, userAgent);
109
+ }
110
+
111
+ // 7. Jika jawaban langsung (text)
112
+ const balasan = result.balasan || 'Maaf, saya tidak bisa merespons saat ini. Coba lagi nanti.';
113
+ addToConversation(user.id, { role: 'assistant', content: balasan });
114
+
115
+ return { balasan };
116
+ } catch (err: any) {
117
+ console.error('Chatbot AI Error:', err);
118
+ const errorMsg = 'Maaf, terjadi gangguan pada sistem AI. Silakan coba lagi dalam beberapa saat. 🔄';
119
+ addToConversation(user.id, { role: 'assistant', content: errorMsg });
120
+ return { balasan: errorMsg };
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Handle tool calls dari AI response.
126
+ */
127
+ private static async handleToolCalls(
128
+ aiResult: any,
129
+ messages: PesanChat[],
130
+ user: UserInfo,
131
+ ip: string,
132
+ userAgent: string
133
+ ): Promise<ChatbotResponse> {
134
+ const toolCall = aiResult.tool_calls![0]; // Handle satu tool call pertama
135
+ const handler = TOOL_HANDLERS[toolCall.nama];
136
+
137
+ if (!handler) {
138
+ const balasan = `Tool "${toolCall.nama}" tidak ditemukan.`;
139
+ addToConversation(user.id, { role: 'assistant', content: balasan });
140
+ return { balasan };
141
+ }
142
+
143
+ // Cek akses admin
144
+ if (handler.adminOnly && user.role !== 'admin') {
145
+ const balasan = `Maaf, fitur ini hanya bisa diakses oleh admin. Silakan hubungi admin toko Anda. 🔒`;
146
+ addToConversation(user.id, { role: 'assistant', content: balasan });
147
+ return { balasan };
148
+ }
149
+
150
+ // Jika butuh konfirmasi → return preview dulu
151
+ if (handler.butuhKonfirmasi) {
152
+ const aksiId = generateAksiId();
153
+
154
+ // Buat preview text
155
+ const preview = this.buildPreview(toolCall.nama, toolCall.argumen);
156
+
157
+ pendingActions.set(aksiId, {
158
+ toolName: toolCall.nama,
159
+ params: toolCall.argumen,
160
+ userId: user.id,
161
+ preview,
162
+ createdAt: new Date(),
163
+ });
164
+
165
+ // Auto-expire after 5 minutes
166
+ setTimeout(() => pendingActions.delete(aksiId), 5 * 60 * 1000);
167
+
168
+ const balasan = `${preview}\n\nKonfirmasi aksi di atas?`;
169
+ addToConversation(user.id, { role: 'assistant', content: balasan });
170
+
171
+ return {
172
+ balasan,
173
+ konfirmasi: {
174
+ aksiId,
175
+ preview,
176
+ toolName: toolCall.nama,
177
+ },
178
+ };
179
+ }
180
+
181
+ // Tidak butuh konfirmasi → eksekusi langsung
182
+ return await this.executeToolAndRespond(toolCall, messages, user, ip, userAgent);
183
+ }
184
+
185
+ /**
186
+ * Eksekusi tool dan dapatkan response final dari AI.
187
+ */
188
+ private static async executeToolAndRespond(
189
+ toolCall: any,
190
+ messages: PesanChat[],
191
+ user: UserInfo,
192
+ ip: string,
193
+ userAgent: string
194
+ ): Promise<ChatbotResponse> {
195
+ const handler = TOOL_HANDLERS[toolCall.nama];
196
+
197
+ // Eksekusi tool
198
+ let toolResult: string;
199
+ try {
200
+ toolResult = await handler.handler(toolCall.argumen, user.id, ip, userAgent);
201
+ } catch (err: any) {
202
+ toolResult = `Error saat menjalankan tool: ${err.message}`;
203
+ }
204
+
205
+ // Kirim hasil tool ke AI untuk format jawaban
206
+ const updatedMessages: PesanChat[] = [
207
+ ...messages,
208
+ {
209
+ role: 'assistant',
210
+ content: null as any,
211
+ tool_calls: [
212
+ {
213
+ id: toolCall.id,
214
+ type: 'function',
215
+ function: {
216
+ name: toolCall.nama,
217
+ arguments: JSON.stringify(toolCall.argumen),
218
+ },
219
+ },
220
+ ],
221
+ },
222
+ {
223
+ role: 'tool',
224
+ content: toolResult,
225
+ tool_call_id: toolCall.id,
226
+ },
227
+ ];
228
+
229
+ try {
230
+ const finalResult = await chatCompletion(updatedMessages);
231
+ const balasan = finalResult.balasan || toolResult;
232
+ addToConversation(user.id, { role: 'assistant', content: balasan });
233
+ return { balasan };
234
+ } catch (err) {
235
+ // Fallback: return tool result directly
236
+ addToConversation(user.id, { role: 'assistant', content: toolResult });
237
+ return { balasan: toolResult };
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Konfirmasi dan eksekusi aksi pending.
243
+ */
244
+ static async konfirmasiAksi(
245
+ aksiId: string,
246
+ setuju: boolean,
247
+ user: UserInfo,
248
+ ip: string,
249
+ userAgent: string
250
+ ): Promise<ChatbotResponse> {
251
+ const action = pendingActions.get(aksiId);
252
+
253
+ if (!action) {
254
+ return { balasan: 'Aksi ini sudah kedaluwarsa atau tidak valid. Silakan ulangi permintaan. ⏰' };
255
+ }
256
+
257
+ if (action.userId !== user.id) {
258
+ return { balasan: 'Anda tidak memiliki akses ke aksi ini. 🔒' };
259
+ }
260
+
261
+ // Hapus pending action
262
+ pendingActions.delete(aksiId);
263
+
264
+ if (!setuju) {
265
+ const balasan = 'Aksi dibatalkan. ❌ Ada yang lain yang bisa saya bantu?';
266
+ addToConversation(user.id, { role: 'assistant', content: balasan });
267
+ return { balasan };
268
+ }
269
+
270
+ // Eksekusi tool
271
+ const handler = TOOL_HANDLERS[action.toolName];
272
+ if (!handler) {
273
+ return { balasan: 'Tool tidak ditemukan. Silakan ulangi permintaan.' };
274
+ }
275
+
276
+ try {
277
+ const result = await handler.handler(action.params, user.id, ip, userAgent);
278
+ const balasan = `✅ ${result}`;
279
+ addToConversation(user.id, { role: 'assistant', content: balasan });
280
+ return { balasan };
281
+ } catch (err: any) {
282
+ const balasan = `❌ Gagal mengeksekusi aksi: ${err.message}`;
283
+ addToConversation(user.id, { role: 'assistant', content: balasan });
284
+ return { balasan };
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Hapus riwayat chat user.
290
+ */
291
+ static hapusRiwayat(userId: number): void {
292
+ conversationStore.delete(userId);
293
+ // Hapus pending actions user
294
+ for (const [key, action] of pendingActions) {
295
+ if (action.userId === userId) {
296
+ pendingActions.delete(key);
297
+ }
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Build preview text untuk konfirmasi.
303
+ */
304
+ private static buildPreview(toolName: string, params: any): string {
305
+ const formatRupiah = (n: number) => 'Rp ' + n.toLocaleString('id-ID');
306
+
307
+ switch (toolName) {
308
+ case 'tambah_produk':
309
+ return [
310
+ '📦 Menambahkan produk baru:',
311
+ ` • Nama: ${params.nama}`,
312
+ ` • Harga Jual: ${formatRupiah(params.harga_jual)}`,
313
+ ` • Harga Beli: ${formatRupiah(params.harga_beli)}`,
314
+ ` • Kategori ID: ${params.kategori_id}`,
315
+ ` • Satuan: ${params.satuan || 'pcs'}`,
316
+ params.stok ? ` • Stok Awal: ${params.stok}` : '',
317
+ ].filter(Boolean).join('\n');
318
+
319
+ case 'update_produk':
320
+ const fields = Object.entries(params)
321
+ .filter(([k, v]) => k !== 'id' && v !== undefined)
322
+ .map(([k, v]) => ` • ${k}: ${typeof v === 'number' && k.includes('harga') ? formatRupiah(v as number) : v}`);
323
+ return [
324
+ `✏️ Mengupdate produk ID ${params.id}:`,
325
+ ...fields,
326
+ ].join('\n');
327
+
328
+ case 'hapus_produk':
329
+ return `🗑️ Menghapus produk dengan ID ${params.id}`;
330
+
331
+ case 'void_transaksi':
332
+ return `⚠️ Membatalkan (void) Transaksi: ${params.no_transaksi || `ID ${params.id}`}\n • Alasan: ${params.alasan}`;
333
+
334
+ case 'tambah_stok_masuk':
335
+ return [
336
+ '📥 Menambah stok masuk:',
337
+ ` • Produk: ${params.nama_produk || `ID ${params.produk_id}`}`,
338
+ ` • Qty: ${params.qty}`,
339
+ ` • Harga Beli: ${formatRupiah(params.harga_beli)}`,
340
+ params.catatan ? ` • Catatan: ${params.catatan}` : '',
341
+ ].filter(Boolean).join('\n');
342
+
343
+ case 'tambah_member':
344
+ return [
345
+ '👤 Mendaftarkan member baru:',
346
+ ` • Nama: ${params.nama}`,
347
+ ` • HP: ${params.nomor_hp}`,
348
+ params.alamat ? ` • Alamat: ${params.alamat}` : '',
349
+ ` • Limit Bon: ${formatRupiah(params.limit_bon || 0)}`,
350
+ ].filter(Boolean).join('\n');
351
+
352
+ case 'tambah_supplier':
353
+ return [
354
+ '🚚 Menambah supplier baru:',
355
+ ` • Nama: ${params.nama}`,
356
+ params.kontak ? ` • Kontak: ${params.kontak}` : '',
357
+ params.nomor_hp ? ` • HP: ${params.nomor_hp}` : '',
358
+ ].filter(Boolean).join('\n');
359
+
360
+ case 'tambah_kategori':
361
+ return [
362
+ '📂 Menambah kategori baru:',
363
+ ` • Nama: ${params.nama}`,
364
+ params.icon ? ` • Icon: ${params.icon}` : '',
365
+ ].filter(Boolean).join('\n');
366
+
367
+ case 'update_kategori':
368
+ const katFields = Object.entries(params)
369
+ .filter(([k, v]) => k !== 'id' && v !== undefined)
370
+ .map(([k, v]) => ` • ${k}: ${v}`);
371
+ return [
372
+ `✏️ Mengupdate kategori ID ${params.id}:`,
373
+ ...katFields,
374
+ ].join('\n');
375
+
376
+ case 'hapus_kategori':
377
+ return `🗑️ Menghapus kategori produk: ${params.nama || `ID ${params.id}`}`;
378
+
379
+ case 'tambah_diskon':
380
+ return [
381
+ '🏷️ Membuat promosi diskon baru:',
382
+ ` • Nama: ${params.nama}`,
383
+ ` • Tipe: ${params.tipe}`,
384
+ ` • Nilai: ${params.nilai}`,
385
+ ` • Min Belanja: ${formatRupiah(params.min_belanja || 0)}`,
386
+ ` • Berlaku: ${params.tgl_mulai} s/d ${params.tgl_selesai}`,
387
+ ` • Khusus Member: ${params.member_only === 1 ? 'Ya' : 'Tidak'}`,
388
+ ].join('\n');
389
+
390
+ case 'update_diskon':
391
+ const discFields = Object.entries(params)
392
+ .filter(([k, v]) => k !== 'id' && v !== undefined)
393
+ .map(([k, v]) => ` • ${k}: ${k === 'nilai' || k === 'min_belanja' ? formatRupiah(Number(v)) : v}`);
394
+ return [
395
+ `✏️ Mengupdate diskon ID ${params.id}:`,
396
+ ...discFields,
397
+ ].join('\n');
398
+
399
+ case 'hapus_diskon':
400
+ return `🗑️ Menghapus promosi diskon dengan ID ${params.id}`;
401
+
402
+ default:
403
+ return `Menjalankan aksi: ${toolName} dengan parameter: ${JSON.stringify(params)}`;
404
+ }
405
+ }
406
+ }
src/services/ops.service.ts CHANGED
@@ -2,6 +2,10 @@ import prisma from '../utils/db';
2
  import { catatAudit } from '../utils/audit';
3
  import { generateKodeShift } from '../utils/kodeGenerator';
4
  import { APIError } from '../utils/errors';
 
 
 
 
5
 
6
  export class OpsService {
7
  static async getActiveShift(userId: number) {
@@ -255,6 +259,41 @@ export class OpsService {
255
  });
256
  }
257
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
258
  return {
259
  penjualan_hari_ini: penjualanHariIni,
260
  jumlah_transaksi_hari_ini: jumlahTransaksiHariIni,
@@ -262,6 +301,8 @@ export class OpsService {
262
  stok_rendah: stokRendah,
263
  total_piutang: totalPiutang,
264
  grafik_penjualan_7_hari: grafik7Hari,
 
 
265
  };
266
  }
267
 
@@ -365,4 +406,170 @@ export class OpsService {
365
  margin_laba_persen: marginLaba,
366
  };
367
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
368
  }
 
2
  import { catatAudit } from '../utils/audit';
3
  import { generateKodeShift } from '../utils/kodeGenerator';
4
  import { APIError } from '../utils/errors';
5
+ import fs from 'fs';
6
+ import path from 'path';
7
+ import PDFDocument from 'pdfkit';
8
+ import { uploadBufferToSupabase } from '../utils/supabase';
9
 
10
  export class OpsService {
11
  static async getActiveShift(userId: number) {
 
259
  });
260
  }
261
 
262
+ // 7. Top 5 Produk Terlaris (7 hari terakhir)
263
+ const tujuhHariLalu = new Date();
264
+ tujuhHariLalu.setDate(tujuhHariLalu.getDate() - 7);
265
+ tujuhHariLalu.setHours(0, 0, 0, 0);
266
+
267
+ const terlarisRaw = await prisma.$queryRaw<any[]>`
268
+ SELECT td.nama_produk as nama, SUM(td.qty) as terjual
269
+ FROM transaksi_detail td
270
+ JOIN transaksi t ON t.id = td.transaksi_id
271
+ WHERE t.deleted_at IS NULL AND t.status != 'void' AND t.tanggal >= ${tujuhHariLalu}
272
+ GROUP BY td.produk_id, td.nama_produk
273
+ ORDER BY terjual DESC
274
+ LIMIT 5
275
+ `;
276
+
277
+ const produkTerlaris = terlarisRaw.map((p) => ({
278
+ nama: p.nama,
279
+ terjual: Number(p.terjual),
280
+ }));
281
+
282
+ // 8. Bon Jatuh Tempo Aktif
283
+ const bonJatuhTempoRaw = await prisma.bon.findMany({
284
+ where: { status: 'aktif' },
285
+ include: { member: { select: { nama: true } } },
286
+ orderBy: { jatuh_tempo: 'asc' },
287
+ take: 5,
288
+ });
289
+
290
+ const bonJatuhTempo = bonJatuhTempoRaw.map((b) => ({
291
+ nama: b.member.nama,
292
+ nominal: Number(b.sisa_bon),
293
+ jatuhTempo: b.jatuh_tempo ? b.jatuh_tempo.toISOString().split('T')[0] : '',
294
+ status: b.status,
295
+ }));
296
+
297
  return {
298
  penjualan_hari_ini: penjualanHariIni,
299
  jumlah_transaksi_hari_ini: jumlahTransaksiHariIni,
 
301
  stok_rendah: stokRendah,
302
  total_piutang: totalPiutang,
303
  grafik_penjualan_7_hari: grafik7Hari,
304
+ produk_terlaris: produkTerlaris,
305
+ bon_jatuh_tempo: bonJatuhTempo,
306
  };
307
  }
308
 
 
406
  margin_laba_persen: marginLaba,
407
  };
408
  }
409
+
410
+ static async eksporLaporan(params: {
411
+ jenis: 'penjualan' | 'laba-rugi' | 'mutasi-stok';
412
+ format: 'csv' | 'excel' | 'pdf';
413
+ tglMulai: Date;
414
+ tglSelesai: Date;
415
+ }): Promise<{ filename: string; url: string }> {
416
+ const { jenis, format, tglMulai, tglSelesai } = params;
417
+ const endOfDay = new Date(tglSelesai);
418
+ endOfDay.setHours(23, 59, 59, 999);
419
+
420
+ const timestamp = Date.now();
421
+ const ext = format === 'pdf' ? 'pdf' : 'csv';
422
+ const filename = `laporan_${jenis}_${timestamp}.${ext}`;
423
+ const filePath = path.join(__dirname, '../public/exports', filename);
424
+
425
+ // Pastikan folder exports ada sebelum menulis berkas
426
+ const exportDir = path.dirname(filePath);
427
+ if (!fs.existsSync(exportDir)) {
428
+ fs.mkdirSync(exportDir, { recursive: true });
429
+ }
430
+
431
+ if (ext === 'csv') {
432
+ let content = '';
433
+ if (jenis === 'penjualan') {
434
+ const data = await prisma.transaksi.findMany({
435
+ where: { tanggal: { gte: tglMulai, lte: endOfDay }, deleted_at: null },
436
+ include: { pembayaran: true, user: { select: { nama: true } } },
437
+ orderBy: { tanggal: 'asc' },
438
+ });
439
+
440
+ content = 'No. Invoice;Tanggal;Waktu;Kasir;Total;Diskon;Kembalian;Status;Metode Pembayaran\n';
441
+ data.forEach((tx) => {
442
+ const metode = tx.pembayaran.map((p) => p.metode).join(', ');
443
+ content += `${tx.no_transaksi};${new Date(tx.tanggal).toISOString().split('T')[0]};${tx.waktu || ''};${tx.user?.nama || ''};${Number(tx.total)};${Number(tx.diskon_total)};${Number(tx.kembalian)};${tx.status};${metode}\n`;
444
+ });
445
+ } else if (jenis === 'laba-rugi') {
446
+ const details = await prisma.transaksi_detail.findMany({
447
+ where: { transaksi: { tanggal: { gte: tglMulai, lte: endOfDay }, deleted_at: null, status: { not: 'void' } } },
448
+ include: { produk: true, transaksi: true },
449
+ });
450
+
451
+ content = 'No. Invoice;Tanggal;Produk;Qty;Harga Satuan;Subtotal;Harga Beli (HPP);Laba Kotor\n';
452
+ details.forEach((d) => {
453
+ const hpp = Number(d.produk?.harga_beli ?? d.harga_satuan);
454
+ const laba = Number(d.subtotal) - (hpp * Number(d.qty));
455
+ content += `${d.transaksi.no_transaksi};${new Date(d.transaksi.tanggal).toISOString().split('T')[0]};${d.nama_produk};${Number(d.qty)};${Number(d.harga_satuan)};${Number(d.subtotal)};${hpp * Number(d.qty)};${laba}\n`;
456
+ });
457
+ } else if (jenis === 'mutasi-stok') {
458
+ const logs = await prisma.stok_log.findMany({
459
+ where: { created_at: { gte: tglMulai, lte: endOfDay } },
460
+ include: { produk: true, user: { select: { nama: true } } },
461
+ orderBy: { created_at: 'desc' },
462
+ });
463
+
464
+ content = 'Tanggal;Produk;Tipe Mutasi;Qty;Stok Sebelum;Stok Sesudah;Kasir;Catatan\n';
465
+ logs.forEach((log) => {
466
+ content += `${new Date(log.created_at).toISOString().replace('T', ' ').substring(0, 19)};${log.produk.nama};${log.tipe};${Number(log.qty)};${Number(log.stok_sebelum)};${Number(log.stok_sesudah)};${log.user?.nama || ''};${log.catatan || ''}\n`;
467
+ });
468
+ }
469
+ fs.writeFileSync(filePath, content, 'utf-8');
470
+ } else {
471
+ // PDF format menggunakan pdfkit
472
+ const doc = new PDFDocument({ margin: 30 });
473
+ const stream = fs.createWriteStream(filePath);
474
+ doc.pipe(stream);
475
+
476
+ // Header PDF
477
+ doc.fontSize(18).text('TOKIVA POINT OF SALE', { align: 'center' });
478
+ doc.fontSize(12).text(`Laporan ${jenis.toUpperCase().replace('-', ' ')}`, { align: 'center' });
479
+ doc.fontSize(9).text(`Periode: ${tglMulai.toISOString().split('T')[0]} s/d ${tglSelesai.toISOString().split('T')[0]}`, { align: 'center' });
480
+ doc.moveDown(1.5);
481
+
482
+ if (jenis === 'penjualan') {
483
+ const data = await prisma.transaksi.findMany({
484
+ where: { tanggal: { gte: tglMulai, lte: endOfDay }, deleted_at: null },
485
+ include: { user: { select: { nama: true } } },
486
+ orderBy: { tanggal: 'asc' },
487
+ });
488
+
489
+ doc.fontSize(10).text('Daftar Transaksi:', { underline: true });
490
+ doc.moveDown(0.5);
491
+ let totalOmset = 0;
492
+
493
+ data.forEach((tx, idx) => {
494
+ totalOmset += Number(tx.total);
495
+ doc.fontSize(9).text(`${idx + 1}. Invoice: ${tx.no_transaksi} | Tanggal: ${new Date(tx.tanggal).toISOString().split('T')[0]} | Kasir: ${tx.user?.nama || '-'} | Total: Rp ${Number(tx.total).toLocaleString('id-ID')} (${tx.status})`);
496
+ });
497
+
498
+ doc.moveDown(1);
499
+ doc.font('Helvetica-Bold').fontSize(11).text(`TOTAL OMZET: Rp ${totalOmset.toLocaleString('id-ID')}`);
500
+ doc.font('Helvetica');
501
+ } else if (jenis === 'laba-rugi') {
502
+ const details = await prisma.transaksi_detail.findMany({
503
+ where: { transaksi: { tanggal: { gte: tglMulai, lte: endOfDay }, deleted_at: null, status: { not: 'void' } } },
504
+ include: { produk: true },
505
+ });
506
+
507
+ let totalOmset = 0;
508
+ let totalHpp = 0;
509
+
510
+ details.forEach((d) => {
511
+ totalOmset += Number(d.subtotal);
512
+ const hppVal = Number(d.produk?.harga_beli ?? d.harga_satuan) * Number(d.qty);
513
+ totalHpp += hppVal;
514
+ });
515
+
516
+ const labaKotor = totalOmset - totalHpp;
517
+ const margin = totalOmset > 0 ? (labaKotor / totalOmset) * 100 : 0;
518
+
519
+ doc.fontSize(10).text('Ringkasan Laba Rugi:', { underline: true });
520
+ doc.moveDown(0.5);
521
+ doc.fontSize(9).text(`Total Omzet Penjualan: Rp ${totalOmset.toLocaleString('id-ID')}`);
522
+ doc.fontSize(9).text(`Total Harga Pokok Penjualan (HPP): Rp ${totalHpp.toLocaleString('id-ID')}`);
523
+ doc.moveDown(0.5);
524
+ doc.font('Helvetica-Bold').fontSize(11).text(`LABA KOTOR: Rp ${labaKotor.toLocaleString('id-ID')}`);
525
+ doc.font('Helvetica');
526
+ doc.fontSize(9).text(`Margin Laba: ${margin.toFixed(2)}%`);
527
+ } else if (jenis === 'mutasi-stok') {
528
+ const logs = await prisma.stok_log.findMany({
529
+ where: { created_at: { gte: tglMulai, lte: endOfDay } },
530
+ include: { produk: true },
531
+ orderBy: { created_at: 'desc' },
532
+ });
533
+
534
+ doc.fontSize(10).text('Mutasi Stok Barang:', { underline: true });
535
+ doc.moveDown(0.5);
536
+
537
+ logs.forEach((log, idx) => {
538
+ doc.fontSize(8).text(`${idx + 1}. [${new Date(log.created_at).toISOString().replace('T', ' ').substring(0, 19)}] ${log.produk.nama} | Tipe: ${log.tipe} | Qty: ${Number(log.qty)} | Stok: ${Number(log.stok_sebelum)} -> ${Number(log.stok_sesudah)} | Catatan: ${log.catatan || '-'}`);
539
+ });
540
+ }
541
+
542
+ doc.end();
543
+
544
+ // Tunggu stream selesai menulis file ke disk
545
+ await new Promise<void>((resolve) => stream.on('finish', () => resolve()));
546
+ }
547
+
548
+ // Upload ke Supabase Storage
549
+ let fileBuffer: Buffer | null = null;
550
+ try {
551
+ fileBuffer = fs.readFileSync(filePath);
552
+ } catch (readErr) {
553
+ console.error('Gagal membaca file laporan:', readErr);
554
+ }
555
+
556
+ let downloadUrl = '';
557
+ if (fileBuffer) {
558
+ const mimeType = format === 'pdf' ? 'application/pdf' : 'text/csv';
559
+ const supabaseUrl = await uploadBufferToSupabase('exports', filename, fileBuffer, mimeType);
560
+ if (supabaseUrl) {
561
+ downloadUrl = supabaseUrl;
562
+ }
563
+ }
564
+
565
+ if (!downloadUrl) {
566
+ const host = process.env.BACKEND_URL || 'http://localhost:5000';
567
+ downloadUrl = `${host}/exports/${filename}`;
568
+ }
569
+
570
+ return {
571
+ filename,
572
+ url: downloadUrl,
573
+ };
574
+ }
575
  }
src/utils/ai-client.ts ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * ai-client.ts
3
+ * Client untuk berkomunikasi dengan AI melalui OpenRouter API.
4
+ * Menggunakan SDK OpenAI karena OpenRouter API-nya OpenAI-compatible.
5
+ */
6
+
7
+ import OpenAI from 'openai';
8
+ import type { ChatCompletionMessageParam, ChatCompletionTool } from 'openai/resources/chat/completions';
9
+
10
+ // Inisialisasi client OpenAI dengan baseURL OpenRouter
11
+ // Lazy initialization client OpenAI menggunakan konfigurasi dari .env
12
+ let openai: OpenAI | null = null;
13
+
14
+ function getOpenAI(): OpenAI {
15
+ if (openai) return openai;
16
+
17
+ const aiBaseUrl = process.env.AI_BASE_URL;
18
+ const aiApiKey = process.env.AI_API_KEY;
19
+ const aiModel = process.env.AI_MODEL;
20
+
21
+ if (!aiApiKey || !aiBaseUrl || !aiModel) {
22
+ throw new Error('Konfigurasi AI tidak lengkap di file .env (Pastikan AI_BASE_URL, AI_API_KEY, dan AI_MODEL terisi).');
23
+ }
24
+
25
+ openai = new OpenAI({
26
+ baseURL: aiBaseUrl,
27
+ apiKey: aiApiKey,
28
+ });
29
+
30
+ return openai;
31
+ }
32
+
33
+ export interface PesanChat {
34
+ role: 'system' | 'user' | 'assistant' | 'tool';
35
+ content: string;
36
+ tool_call_id?: string;
37
+ tool_calls?: any[];
38
+ }
39
+
40
+ export interface HasilAI {
41
+ balasan: string | null;
42
+ tool_calls: ToolCall[] | null;
43
+ usage?: {
44
+ prompt_tokens: number;
45
+ completion_tokens: number;
46
+ total_tokens: number;
47
+ };
48
+ }
49
+
50
+ export interface ToolCall {
51
+ id: string;
52
+ nama: string;
53
+ argumen: Record<string, any>;
54
+ }
55
+
56
+ /**
57
+ * Kirim pesan ke AI dan dapatkan response.
58
+ * Support function calling / tool use.
59
+ */
60
+ export async function chatCompletion(
61
+ messages: PesanChat[],
62
+ tools?: ChatCompletionTool[]
63
+ ): Promise<HasilAI> {
64
+ const model = process.env.AI_MODEL!;
65
+ const maxTokens = Number(process.env.AI_MAX_TOKENS) || 2000;
66
+
67
+ const requestMessages: ChatCompletionMessageParam[] = messages.map((m) => {
68
+ if (m.role === 'tool') {
69
+ return {
70
+ role: 'tool' as const,
71
+ content: m.content,
72
+ tool_call_id: m.tool_call_id || '',
73
+ };
74
+ }
75
+ if (m.role === 'assistant' && m.tool_calls) {
76
+ return {
77
+ role: 'assistant' as const,
78
+ content: m.content || null,
79
+ tool_calls: m.tool_calls,
80
+ };
81
+ }
82
+ return {
83
+ role: m.role as 'system' | 'user' | 'assistant',
84
+ content: m.content,
85
+ };
86
+ });
87
+
88
+ const params: any = {
89
+ model,
90
+ messages: requestMessages,
91
+ max_tokens: maxTokens,
92
+ temperature: 0.7,
93
+ };
94
+
95
+ // Hanya tambahkan tools jika ada
96
+ if (tools && tools.length > 0) {
97
+ params.tools = tools;
98
+ params.tool_choice = 'auto';
99
+ }
100
+
101
+ const response = await getOpenAI().chat.completions.create(params);
102
+
103
+ const choice = response.choices[0];
104
+
105
+ // Parse tool calls jika ada secara native
106
+ let toolCalls: ToolCall[] | null = null;
107
+ if (choice.message.tool_calls && choice.message.tool_calls.length > 0) {
108
+ toolCalls = choice.message.tool_calls.map((tc) => ({
109
+ id: tc.id,
110
+ nama: (tc as any).function.name,
111
+ argumen: JSON.parse((tc as any).function.arguments || '{}'),
112
+ }));
113
+ }
114
+
115
+ let balasan = choice.message.content || null;
116
+
117
+ // Fallback parser jika AI menulis tag DSML secara literal di teks (karena limitasi API/model gratis)
118
+ if (!toolCalls && balasan) {
119
+ const dsmlCalls = parseDsmlToolCalls(balasan);
120
+ if (dsmlCalls) {
121
+ toolCalls = dsmlCalls;
122
+ // Bersihkan teks tag XML/DSML dari balasan agar bubble chat rapi
123
+ balasan = balasan
124
+ .replace(/<\s*\|?\s*\|?\s*DSML[\s\S]*?<\s*\/\s*\|?\s*\|?\s*DSML\s*\|?\s*\|?\s*tool_calls\s*>/g, '')
125
+ // Hapus juga tag invoke/tool_calls sisa jika terpisah
126
+ .replace(/<\s*\|?\s*\|?\s*DSML[\s\S]*?>/g, '')
127
+ .replace(/<\s*\/\s*\|?\s*\|?\s*DSML[\s\S]*?>/g, '')
128
+ .trim();
129
+ }
130
+ }
131
+
132
+ return {
133
+ balasan,
134
+ tool_calls: toolCalls,
135
+ usage: response.usage
136
+ ? {
137
+ prompt_tokens: response.usage.prompt_tokens,
138
+ completion_tokens: response.usage.completion_tokens,
139
+ total_tokens: response.usage.total_tokens,
140
+ }
141
+ : undefined,
142
+ };
143
+ }
144
+
145
+ /**
146
+ * Parser penanganan tag DSML/XML tool calls yang dikirim literal oleh model AI
147
+ */
148
+ function parseDsmlToolCalls(text: string): ToolCall[] | null {
149
+ if (!text.includes('tool_calls') && !text.includes('DSML')) return null;
150
+
151
+ try {
152
+ const toolCalls: ToolCall[] = [];
153
+
154
+ // Regex fleksibel mencocokkan invoke block: < | | DSML | | invoke name="NAMA"> ... </ | | DSML | | invoke>
155
+ const invokeRegex = /<\s*\|?\s*\|?\s*DSML\s*\|?\s*\|?\s*invoke\s+name="([^"]+)"\s*>([\s\S]*?)<\s*\/\s*\|?\s*\|?\s*DSML\s*\|?\s*\|?\s*invoke\s*>/g;
156
+
157
+ let match;
158
+ let idx = 0;
159
+ while ((match = invokeRegex.exec(text)) !== null) {
160
+ const toolName = match[1];
161
+ const innerContent = match[2];
162
+ const args: Record<string, any> = {};
163
+
164
+ // Regex mencocokkan argument: <arg name="NAMA">NILAI</arg>
165
+ const argRegex = /<\s*(?:arg|parameter)\s+name="([^"]+)"\s*>([\s\S]*?)<\s*\/\s*(?:arg|parameter)\s*>/g;
166
+ let argMatch;
167
+ while ((argMatch = argRegex.exec(innerContent)) !== null) {
168
+ const argName = argMatch[1];
169
+ const argValue = argMatch[2].trim();
170
+
171
+ if (/^-?\d+$/.test(argValue)) {
172
+ args[argName] = parseInt(argValue, 10);
173
+ } else if (/^-?\d+\.\d+$/.test(argValue)) {
174
+ args[argName] = parseFloat(argValue);
175
+ } else {
176
+ args[argName] = argValue;
177
+ }
178
+ }
179
+
180
+ toolCalls.push({
181
+ id: `dsml_${Date.now()}_${idx++}`,
182
+ nama: toolName,
183
+ argumen: args,
184
+ });
185
+ }
186
+
187
+ return toolCalls.length > 0 ? toolCalls : null;
188
+ } catch (err) {
189
+ console.error('Gagal parsing DSML tool calls:', err);
190
+ return null;
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Kirim pesan sederhana tanpa tools (untuk format ulang jawaban dll).
196
+ */
197
+ export async function chatSederhana(systemPrompt: string, userMessage: string): Promise<string> {
198
+ const hasil = await chatCompletion([
199
+ { role: 'system', content: systemPrompt },
200
+ { role: 'user', content: userMessage },
201
+ ]);
202
+ return hasil.balasan || '';
203
+ }
src/utils/chatbot-context.ts ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * chatbot-context.ts
3
+ * Membangun system prompt + konteks data toko real-time untuk AI.
4
+ * Ini yang membuat chatbot "paham" kondisi toko saat ini.
5
+ */
6
+
7
+ import prisma from './db';
8
+
9
+ interface KonteksToko {
10
+ totalProdukAktif: number;
11
+ produkStokRendah: { nama: string; stok: number; stok_min: number }[];
12
+ produkStokHabis: { nama: string }[];
13
+ produkKadaluarsa: { nama: string; expired_date: string }[];
14
+ produkMendekatiKadaluarsa: { nama: string; expired_date: string }[];
15
+ transaksiHariIni: { jumlah: number; total: number };
16
+ totalMember: number;
17
+ totalSupplier: number;
18
+ totalPiutangBon: number;
19
+ totalKategori: number;
20
+ shiftAktif: { nama_kasir: string; waktu_buka: string } | null;
21
+ }
22
+
23
+ /**
24
+ * Query database untuk mendapatkan ringkasan kondisi toko saat ini.
25
+ */
26
+ export async function getKonteksToko(): Promise<KonteksToko> {
27
+ const hariIni = new Date();
28
+ hariIni.setHours(0, 0, 0, 0);
29
+ const besok = new Date(hariIni);
30
+ besok.setDate(besok.getDate() + 1);
31
+
32
+ const batasMendekati = new Date();
33
+ batasMendekati.setDate(batasMendekati.getDate() + 30);
34
+
35
+ // Query parallel untuk performa
36
+ const [
37
+ totalProdukAktif,
38
+ produkStokRendah,
39
+ produkStokHabis,
40
+ produkKadaluarsa,
41
+ produkMendekatiKadaluarsa,
42
+ transaksiHariIni,
43
+ totalMember,
44
+ totalSupplier,
45
+ bonAktif,
46
+ totalKategori,
47
+ shiftAktif,
48
+ ] = await Promise.all([
49
+ // Total produk aktif
50
+ prisma.produk.count({
51
+ where: { deleted_at: null, is_aktif: 1 },
52
+ }),
53
+
54
+ // Produk stok rendah (stok > 0 tapi <= stok_min)
55
+ prisma.$queryRaw<{ nama: string; stok: number; stok_min: number }[]>`
56
+ SELECT nama, stok, stok_min FROM produk
57
+ WHERE deleted_at IS NULL AND is_aktif = 1 AND stok > 0 AND stok <= stok_min
58
+ LIMIT 10
59
+ `,
60
+
61
+ // Produk stok habis (stok = 0)
62
+ prisma.produk.findMany({
63
+ where: { deleted_at: null, is_aktif: 1, stok: 0 },
64
+ select: { nama: true },
65
+ take: 10,
66
+ }),
67
+
68
+ // Produk sudah kadaluarsa
69
+ prisma.produk.findMany({
70
+ where: {
71
+ deleted_at: null,
72
+ is_aktif: 1,
73
+ expired_date: { lt: hariIni },
74
+ },
75
+ select: { nama: true, expired_date: true },
76
+ take: 10,
77
+ }),
78
+
79
+ // Produk mendekati kadaluarsa (30 hari ke depan)
80
+ prisma.produk.findMany({
81
+ where: {
82
+ deleted_at: null,
83
+ is_aktif: 1,
84
+ expired_date: { gte: hariIni, lte: batasMendekati },
85
+ },
86
+ select: { nama: true, expired_date: true },
87
+ take: 10,
88
+ }),
89
+
90
+ // Transaksi hari ini
91
+ prisma.transaksi.aggregate({
92
+ where: {
93
+ tanggal: { gte: hariIni, lt: besok },
94
+ deleted_at: null,
95
+ status: { not: 'void' },
96
+ },
97
+ _count: { id: true },
98
+ _sum: { total: true },
99
+ }),
100
+
101
+ // Total member
102
+ prisma.member.count({
103
+ where: { deleted_at: null, aktif: 1 },
104
+ }),
105
+
106
+ // Total supplier
107
+ prisma.supplier.count({
108
+ where: { deleted_at: null, aktif: 1 },
109
+ }),
110
+
111
+ // Total piutang bon aktif
112
+ prisma.bon.aggregate({
113
+ where: { status: 'aktif' },
114
+ _sum: { sisa_bon: true },
115
+ }),
116
+
117
+ // Total kategori
118
+ prisma.kategori.count({
119
+ where: { deleted_at: null, aktif: 1 },
120
+ }),
121
+
122
+ // Shift aktif saat ini
123
+ prisma.shift.findFirst({
124
+ where: { status: 'buka' },
125
+ include: { user: { select: { nama: true } } },
126
+ orderBy: { waktu_buka: 'desc' },
127
+ }),
128
+ ]);
129
+
130
+ return {
131
+ totalProdukAktif,
132
+ produkStokRendah: produkStokRendah.map((p) => ({
133
+ nama: p.nama,
134
+ stok: Number(p.stok),
135
+ stok_min: Number(p.stok_min),
136
+ })),
137
+ produkStokHabis: produkStokHabis.map((p) => ({ nama: p.nama })),
138
+ produkKadaluarsa: produkKadaluarsa.map((p) => ({
139
+ nama: p.nama,
140
+ expired_date: p.expired_date ? new Date(p.expired_date).toISOString().split('T')[0] : '',
141
+ })),
142
+ produkMendekatiKadaluarsa: produkMendekatiKadaluarsa.map((p) => ({
143
+ nama: p.nama,
144
+ expired_date: p.expired_date ? new Date(p.expired_date).toISOString().split('T')[0] : '',
145
+ })),
146
+ transaksiHariIni: {
147
+ jumlah: transaksiHariIni._count.id || 0,
148
+ total: Number(transaksiHariIni._sum.total || 0),
149
+ },
150
+ totalMember,
151
+ totalSupplier,
152
+ totalPiutangBon: Number(bonAktif._sum.sisa_bon || 0),
153
+ totalKategori,
154
+ shiftAktif: shiftAktif
155
+ ? {
156
+ nama_kasir: shiftAktif.user.nama,
157
+ waktu_buka: shiftAktif.waktu_buka.toISOString(),
158
+ }
159
+ : null,
160
+ };
161
+ }
162
+
163
+ /**
164
+ * Bangun system prompt lengkap dengan konteks data toko.
165
+ */
166
+ export async function bangunSystemPrompt(namaUser: string, roleUser: string): Promise<string> {
167
+ let konteks: KonteksToko;
168
+ try {
169
+ konteks = await getKonteksToko();
170
+ } catch (err) {
171
+ console.error('Gagal mengambil konteks toko:', err);
172
+ // Fallback jika database error
173
+ konteks = {
174
+ totalProdukAktif: 0,
175
+ produkStokRendah: [],
176
+ produkStokHabis: [],
177
+ produkKadaluarsa: [],
178
+ produkMendekatiKadaluarsa: [],
179
+ transaksiHariIni: { jumlah: 0, total: 0 },
180
+ totalMember: 0,
181
+ totalSupplier: 0,
182
+ totalPiutangBon: 0,
183
+ totalKategori: 0,
184
+ shiftAktif: null,
185
+ };
186
+ }
187
+
188
+ const formatRupiah = (n: number) =>
189
+ 'Rp ' + n.toLocaleString('id-ID');
190
+
191
+ // Bangun bagian stok rendah
192
+ const stokRendahStr = konteks.produkStokRendah.length > 0
193
+ ? konteks.produkStokRendah.map((p) => ` - ${p.nama}: ${p.stok} (min: ${p.stok_min})`).join('\n')
194
+ : ' Tidak ada';
195
+
196
+ const stokHabisStr = konteks.produkStokHabis.length > 0
197
+ ? konteks.produkStokHabis.map((p) => ` - ${p.nama}`).join('\n')
198
+ : ' Tidak ada';
199
+
200
+ const expiredStr = konteks.produkKadaluarsa.length > 0
201
+ ? konteks.produkKadaluarsa.map((p) => ` - ${p.nama} (${p.expired_date})`).join('\n')
202
+ : ' Tidak ada';
203
+
204
+ const nearExpiredStr = konteks.produkMendekatiKadaluarsa.length > 0
205
+ ? konteks.produkMendekatiKadaluarsa.map((p) => ` - ${p.nama} (${p.expired_date})`).join('\n')
206
+ : ' Tidak ada';
207
+
208
+ const shiftStr = konteks.shiftAktif
209
+ ? `${konteks.shiftAktif.nama_kasir} (sejak ${new Date(konteks.shiftAktif.waktu_buka).toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' })})`
210
+ : 'Tidak ada shift aktif';
211
+
212
+ // Bagian akses berdasarkan role
213
+ const aksesStr = roleUser === 'admin'
214
+ ? `Kamu memiliki akses PENUH: bisa query data, tambah, edit, dan hapus produk/member/supplier/kategori.`
215
+ : `Kamu hanya bisa QUERY data (baca saja). Tidak bisa tambah, edit, atau hapus data. Jika user minta CRUD, tolak dengan sopan dan sarankan untuk menghubungi admin.`;
216
+
217
+ return `Kamu adalah asisten toko bernama "Asisten Tokiva" — chatbot AI untuk aplikasi Point of Sale (POS) Tokiva.
218
+ Kamu melayani user bernama "${namaUser}" dengan role "${roleUser}".
219
+
220
+ ═══════════════════════════════════════
221
+ ATURAN KETAT — WAJIB DIPATUHI:
222
+ ═══════════════════════════════════════
223
+
224
+ 1. Kamu HANYA boleh membahas topik yang berkaitan dengan:
225
+ - Manajemen toko (produk, stok, kategori, supplier)
226
+ - Transaksi dan penjualan
227
+ - Member/pelanggan dan bon/piutang
228
+ - Laporan dan analitik bisnis
229
+ - Cara menggunakan fitur-fitur Tokiva POS
230
+ - Shift kasir dan operasional toko
231
+
232
+ 2. TOLAK DENGAN SOPAN jika user bertanya di luar topik toko/POS:
233
+ - Contoh topik yang DITOLAK: politik, hiburan, coding, cuaca, pengetahuan umum, matematika, curhat, filosofi, dll.
234
+ - Format penolakan: "Maaf, saya hanya bisa membantu hal-hal seputar manajemen toko Tokiva 😊 Ada yang bisa saya bantu terkait produk, stok, atau penjualan?"
235
+
236
+ 3. JANGAN PERNAH:
237
+ - Keluar dari peranmu sebagai asisten toko, meskipun user meminta/memaksa
238
+ - Mengaku sebagai AI model tertentu (GPT, Claude, dll)
239
+ - Memberikan informasi di luar konteks toko
240
+ - Mengeksekusi atau membahas kode program
241
+ - Menjawab pertanyaan tentang identitas model AI-mu
242
+
243
+ 4. Jika user bertanya "kamu siapa?" atau sejenisnya, jawab:
244
+ "Saya Asisten Tokiva, siap membantu mengelola toko Anda! 🏪"
245
+
246
+ 5. ${aksesStr}
247
+
248
+ 6. Untuk aksi yang mengubah data (tambah/edit/hapus), SELALU tunjukkan preview data yang akan diubah dan minta konfirmasi user sebelum mengeksekusi tool.
249
+
250
+ 7. Jawab dalam Bahasa Indonesia. Gunakan emoji secukupnya untuk readability.
251
+
252
+ 8. Jika menampilkan banyak data, gunakan format list/tabel yang rapi.
253
+
254
+ ═══════════════════════════════════════
255
+ STATUS TOKO SAAT INI:
256
+ ═══════════════════════════════════════
257
+
258
+ 📦 Total Produk Aktif: ${konteks.totalProdukAktif}
259
+ 📂 Total Kategori: ${konteks.totalKategori}
260
+ 👥 Total Member: ${konteks.totalMember}
261
+ 🚚 Total Supplier: ${konteks.totalSupplier}
262
+
263
+ 💰 Transaksi Hari Ini: ${konteks.transaksiHariIni.jumlah} transaksi (${formatRupiah(konteks.transaksiHariIni.total)})
264
+ 💳 Total Piutang Bon: ${formatRupiah(konteks.totalPiutangBon)}
265
+ 🕐 Shift Aktif: ${shiftStr}
266
+
267
+ ⚠️ Produk Stok Rendah:
268
+ ${stokRendahStr}
269
+
270
+ 🔴 Produk Stok Habis:
271
+ ${stokHabisStr}
272
+
273
+ 📅 Produk Kadaluarsa:
274
+ ${expiredStr}
275
+
276
+ ⏰ Produk Mendekati Kadaluarsa (30 hari):
277
+ ${nearExpiredStr}
278
+ `;
279
+ }
src/utils/chatbot-tools.ts ADDED
@@ -0,0 +1,1456 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * chatbot-tools.ts
3
+ * Definisi tools (function calling) yang AI bisa panggil untuk berinteraksi dengan database.
4
+ * Setiap tool memanggil existing service layer — tidak akses DB langsung.
5
+ */
6
+
7
+ import type { ChatCompletionTool } from 'openai/resources/chat/completions';
8
+ import prisma from './db';
9
+ import { ProdukService } from '../services/produk.service';
10
+ import { MemberService } from '../services/member.service';
11
+ import { SupplierService } from '../services/supplier.service';
12
+ import { KategoriService } from '../services/kategori.service';
13
+ import { BonService } from '../services/bon.service';
14
+ import { StokService } from '../services/stok.service';
15
+ import { OpsService } from '../services/ops.service';
16
+ import { TransaksiService } from '../services/transaksi.service';
17
+ import { generateKodeProduk, generateKodeMember } from './kodeGenerator';
18
+ import { catatAudit } from './audit';
19
+
20
+ // ═══════════════════════════════════
21
+ // INTERFACE
22
+ // ═══════════════════════════════════
23
+
24
+ export interface ToolHandler {
25
+ nama: string;
26
+ butuhKonfirmasi: boolean;
27
+ adminOnly: boolean;
28
+ handler: (params: any, userId: number, ip: string, userAgent: string) => Promise<string>;
29
+ }
30
+
31
+ // ═══════════════════════════════════
32
+ // TOOL DEFINITIONS (untuk dikirim ke AI)
33
+ // ═══════════════════════════════════
34
+
35
+ export const TOOL_DEFINITIONS: ChatCompletionTool[] = [
36
+ // ── Produk ──
37
+ {
38
+ type: 'function',
39
+ function: {
40
+ name: 'cari_produk',
41
+ description: 'Cari produk berdasarkan nama, kode, atau barcode. Tampilkan hasil pencarian.',
42
+ parameters: {
43
+ type: 'object',
44
+ properties: {
45
+ pencarian: { type: 'string', description: 'Kata kunci pencarian (nama produk, kode, atau barcode)' },
46
+ },
47
+ required: ['pencarian'],
48
+ },
49
+ },
50
+ },
51
+ {
52
+ type: 'function',
53
+ function: {
54
+ name: 'detail_produk',
55
+ description: 'Lihat detail lengkap satu produk berdasarkan ID produk.',
56
+ parameters: {
57
+ type: 'object',
58
+ properties: {
59
+ id: { type: 'number', description: 'ID produk' },
60
+ },
61
+ required: ['id'],
62
+ },
63
+ },
64
+ },
65
+ {
66
+ type: 'function',
67
+ function: {
68
+ name: 'tambah_produk',
69
+ description: 'Tambahkan produk baru ke database. Kode produk akan di-generate otomatis jika tidak diisi.',
70
+ parameters: {
71
+ type: 'object',
72
+ properties: {
73
+ nama: { type: 'string', description: 'Nama produk' },
74
+ harga_jual: { type: 'number', description: 'Harga jual (Rupiah)' },
75
+ harga_beli: { type: 'number', description: 'Harga beli/modal (Rupiah)' },
76
+ kategori_id: { type: 'number', description: 'ID kategori produk' },
77
+ satuan: { type: 'string', description: 'Satuan produk (pcs, kg, liter, pack, dll). Default: pcs' },
78
+ barcode: { type: 'string', description: 'Barcode produk (opsional)' },
79
+ stok_min: { type: 'number', description: 'Stok minimum sebelum warning. Default: 5' },
80
+ stok: { type: 'number', description: 'Stok awal (opsional, default: 0)' },
81
+ },
82
+ required: ['nama', 'harga_jual', 'harga_beli', 'kategori_id'],
83
+ },
84
+ },
85
+ },
86
+ {
87
+ type: 'function',
88
+ function: {
89
+ name: 'update_produk',
90
+ description: 'Update/edit data produk yang sudah ada berdasarkan ID.',
91
+ parameters: {
92
+ type: 'object',
93
+ properties: {
94
+ id: { type: 'number', description: 'ID produk yang ingin diupdate' },
95
+ nama: { type: 'string', description: 'Nama produk baru (opsional)' },
96
+ harga_jual: { type: 'number', description: 'Harga jual baru (opsional)' },
97
+ harga_beli: { type: 'number', description: 'Harga beli baru (opsional)' },
98
+ kategori_id: { type: 'number', description: 'ID kategori baru (opsional)' },
99
+ satuan: { type: 'string', description: 'Satuan baru (opsional)' },
100
+ stok_min: { type: 'number', description: 'Stok minimum baru (opsional)' },
101
+ is_aktif: { type: 'number', description: '1 = aktif, 0 = nonaktif (opsional)' },
102
+ },
103
+ required: ['id'],
104
+ },
105
+ },
106
+ },
107
+ {
108
+ type: 'function',
109
+ function: {
110
+ name: 'hapus_produk',
111
+ description: 'Hapus (soft-delete) produk berdasarkan ID.',
112
+ parameters: {
113
+ type: 'object',
114
+ properties: {
115
+ id: { type: 'number', description: 'ID produk yang ingin dihapus' },
116
+ },
117
+ required: ['id'],
118
+ },
119
+ },
120
+ },
121
+
122
+ // ── Stok ──
123
+ {
124
+ type: 'function',
125
+ function: {
126
+ name: 'cek_stok',
127
+ description: 'Cek stok produk tertentu berdasarkan nama atau ID produk.',
128
+ parameters: {
129
+ type: 'object',
130
+ properties: {
131
+ pencarian: { type: 'string', description: 'Nama atau kode produk untuk dicek stoknya' },
132
+ },
133
+ required: ['pencarian'],
134
+ },
135
+ },
136
+ },
137
+ {
138
+ type: 'function',
139
+ function: {
140
+ name: 'stok_rendah',
141
+ description: 'Lihat daftar semua produk yang stoknya di bawah batas minimum (stok rendah atau habis).',
142
+ parameters: {
143
+ type: 'object',
144
+ properties: {},
145
+ },
146
+ },
147
+ },
148
+ {
149
+ type: 'function',
150
+ function: {
151
+ name: 'stok_kadaluarsa',
152
+ description: 'Lihat daftar produk yang sudah kadaluarsa atau mendekati kadaluarsa (30 hari ke depan).',
153
+ parameters: {
154
+ type: 'object',
155
+ properties: {},
156
+ },
157
+ },
158
+ },
159
+ {
160
+ type: 'function',
161
+ function: {
162
+ name: 'tambah_stok_masuk',
163
+ description: 'Tambahkan stok masuk (restock) untuk produk tertentu.',
164
+ parameters: {
165
+ type: 'object',
166
+ properties: {
167
+ produk_id: { type: 'number', description: 'ID produk yang mau ditambah stoknya (opsional jika nama_produk diisi)' },
168
+ nama_produk: { type: 'string', description: 'Nama produk yang mau ditambah stoknya (opsional jika produk_id diisi)' },
169
+ qty: { type: 'number', description: 'Jumlah stok yang masuk' },
170
+ harga_beli: { type: 'number', description: 'Harga beli per satuan' },
171
+ supplier_id: { type: 'number', description: 'ID supplier (opsional)' },
172
+ catatan: { type: 'string', description: 'Catatan restock (opsional)' },
173
+ },
174
+ required: ['qty', 'harga_beli'],
175
+ },
176
+ },
177
+ },
178
+
179
+ // ── Transaksi ──
180
+ {
181
+ type: 'function',
182
+ function: {
183
+ name: 'ringkasan_penjualan',
184
+ description: 'Lihat ringkasan penjualan (total transaksi dan revenue) berdasarkan periode waktu.',
185
+ parameters: {
186
+ type: 'object',
187
+ properties: {
188
+ periode: {
189
+ type: 'string',
190
+ enum: ['hari_ini', 'kemarin', 'minggu_ini', 'bulan_ini'],
191
+ description: 'Periode waktu untuk ringkasan',
192
+ },
193
+ },
194
+ required: ['periode'],
195
+ },
196
+ },
197
+ },
198
+ {
199
+ type: 'function',
200
+ function: {
201
+ name: 'detail_transaksi',
202
+ description: 'Lihat detail transaksi berdasarkan nomor transaksi.',
203
+ parameters: {
204
+ type: 'object',
205
+ properties: {
206
+ no_transaksi: { type: 'string', description: 'Nomor transaksi (contoh: TKV-20260702-0001)' },
207
+ },
208
+ required: ['no_transaksi'],
209
+ },
210
+ },
211
+ },
212
+ {
213
+ type: 'function',
214
+ function: {
215
+ name: 'void_transaksi',
216
+ description: 'Batalkan (void) transaksi penjualan tertentu berdasarkan nomor transaksi atau ID.',
217
+ parameters: {
218
+ type: 'object',
219
+ properties: {
220
+ no_transaksi: { type: 'string', description: 'Nomor transaksi yang ingin dibatalkan (opsional jika id diisi)' },
221
+ id: { type: 'number', description: 'ID transaksi yang ingin dibatalkan (opsional jika nomor transaksi diisi)' },
222
+ alasan: { type: 'string', description: 'Alasan pembatalan transaksi (wajib)' },
223
+ },
224
+ required: ['alasan'],
225
+ },
226
+ },
227
+ },
228
+
229
+ // ── Member ──
230
+ {
231
+ type: 'function',
232
+ function: {
233
+ name: 'cari_member',
234
+ description: 'Cari member/pelanggan berdasarkan nama, kode, atau nomor HP.',
235
+ parameters: {
236
+ type: 'object',
237
+ properties: {
238
+ pencarian: { type: 'string', description: 'Kata kunci pencarian (nama, kode, atau nomor HP)' },
239
+ },
240
+ required: ['pencarian'],
241
+ },
242
+ },
243
+ },
244
+ {
245
+ type: 'function',
246
+ function: {
247
+ name: 'tambah_member',
248
+ description: 'Daftarkan member/pelanggan baru. Kode member di-generate otomatis.',
249
+ parameters: {
250
+ type: 'object',
251
+ properties: {
252
+ nama: { type: 'string', description: 'Nama member' },
253
+ nomor_hp: { type: 'string', description: 'Nomor HP member' },
254
+ alamat: { type: 'string', description: 'Alamat member (opsional)' },
255
+ limit_bon: { type: 'number', description: 'Limit bon/piutang (opsional, default: 0)' },
256
+ },
257
+ required: ['nama', 'nomor_hp'],
258
+ },
259
+ },
260
+ },
261
+ {
262
+ type: 'function',
263
+ function: {
264
+ name: 'update_member',
265
+ description: 'Update data member/pelanggan berdasarkan ID.',
266
+ parameters: {
267
+ type: 'object',
268
+ properties: {
269
+ id: { type: 'number', description: 'ID member yang ingin diupdate' },
270
+ nama: { type: 'string', description: 'Nama baru member (opsional)' },
271
+ nomor_hp: { type: 'string', description: 'Nomor HP baru (opsional)' },
272
+ alamat: { type: 'string', description: 'Alamat baru (opsional)' },
273
+ limit_bon: { type: 'number', description: 'Limit bon baru (opsional)' },
274
+ },
275
+ required: ['id'],
276
+ },
277
+ },
278
+ },
279
+ {
280
+ type: 'function',
281
+ function: {
282
+ name: 'hapus_member',
283
+ description: 'Hapus data member/pelanggan berdasarkan ID.',
284
+ parameters: {
285
+ type: 'object',
286
+ properties: {
287
+ id: { type: 'number', description: 'ID member yang ingin dihapus' },
288
+ },
289
+ required: ['id'],
290
+ },
291
+ },
292
+ },
293
+ {
294
+ type: 'function',
295
+ function: {
296
+ name: 'info_bon_member',
297
+ description: 'Lihat informasi bon/piutang untuk member tertentu berdasarkan ID member.',
298
+ parameters: {
299
+ type: 'object',
300
+ properties: {
301
+ member_id: { type: 'number', description: 'ID member' },
302
+ },
303
+ required: ['member_id'],
304
+ },
305
+ },
306
+ },
307
+
308
+ // ── Supplier ──
309
+ {
310
+ type: 'function',
311
+ function: {
312
+ name: 'cari_supplier',
313
+ description: 'Cari supplier/pemasok berdasarkan nama.',
314
+ parameters: {
315
+ type: 'object',
316
+ properties: {
317
+ pencarian: { type: 'string', description: 'Nama supplier yang dicari' },
318
+ },
319
+ required: ['pencarian'],
320
+ },
321
+ },
322
+ },
323
+ {
324
+ type: 'function',
325
+ function: {
326
+ name: 'tambah_supplier',
327
+ description: 'Tambahkan supplier/pemasok baru.',
328
+ parameters: {
329
+ type: 'object',
330
+ properties: {
331
+ nama: { type: 'string', description: 'Nama supplier' },
332
+ kontak: { type: 'string', description: 'Nama kontak person (opsional)' },
333
+ nomor_hp: { type: 'string', description: 'Nomor HP supplier (opsional)' },
334
+ alamat: { type: 'string', description: 'Alamat supplier (opsional)' },
335
+ },
336
+ required: ['nama'],
337
+ },
338
+ },
339
+ },
340
+ {
341
+ type: 'function',
342
+ function: {
343
+ name: 'update_supplier',
344
+ description: 'Update data supplier/pemasok berdasarkan ID.',
345
+ parameters: {
346
+ type: 'object',
347
+ properties: {
348
+ id: { type: 'number', description: 'ID supplier yang ingin diupdate' },
349
+ nama: { type: 'string', description: 'Nama baru supplier (opsional)' },
350
+ kontak: { type: 'string', description: 'Nama kontak person baru (opsional)' },
351
+ nomor_hp: { type: 'string', description: 'Nomor HP baru (opsional)' },
352
+ alamat: { type: 'string', description: 'Alamat baru (opsional)' },
353
+ },
354
+ required: ['id'],
355
+ },
356
+ },
357
+ },
358
+ {
359
+ type: 'function',
360
+ function: {
361
+ name: 'hapus_supplier',
362
+ description: 'Hapus data supplier/pemasok berdasarkan ID.',
363
+ parameters: {
364
+ type: 'object',
365
+ properties: {
366
+ id: { type: 'number', description: 'ID supplier yang ingin dihapus' },
367
+ },
368
+ required: ['id'],
369
+ },
370
+ },
371
+ },
372
+
373
+ // ── Laporan ──
374
+ {
375
+ type: 'function',
376
+ function: {
377
+ name: 'produk_terlaris',
378
+ description: 'Lihat daftar produk terlaris berdasarkan jumlah terjual dalam periode tertentu.',
379
+ parameters: {
380
+ type: 'object',
381
+ properties: {
382
+ limit: { type: 'number', description: 'Jumlah top produk yang ditampilkan (default: 10)' },
383
+ periode: {
384
+ type: 'string',
385
+ enum: ['minggu_ini', 'bulan_ini', 'semua'],
386
+ description: 'Periode waktu (default: bulan_ini)',
387
+ },
388
+ },
389
+ },
390
+ },
391
+ },
392
+
393
+ // ── Kategori ──
394
+ {
395
+ type: 'function',
396
+ function: {
397
+ name: 'daftar_kategori',
398
+ description: 'Lihat semua kategori produk yang tersedia.',
399
+ parameters: {
400
+ type: 'object',
401
+ properties: {},
402
+ },
403
+ },
404
+ },
405
+ {
406
+ type: 'function',
407
+ function: {
408
+ name: 'tambah_kategori',
409
+ description: 'Tambahkan kategori produk baru.',
410
+ parameters: {
411
+ type: 'object',
412
+ properties: {
413
+ nama: { type: 'string', description: 'Nama kategori baru' },
414
+ icon: { type: 'string', description: 'Icon/emoji untuk kategori (opsional)' },
415
+ },
416
+ required: ['nama'],
417
+ },
418
+ },
419
+ },
420
+ {
421
+ type: 'function',
422
+ function: {
423
+ name: 'update_kategori',
424
+ description: 'Update nama atau icon kategori berdasarkan ID.',
425
+ parameters: {
426
+ type: 'object',
427
+ properties: {
428
+ id: { type: 'number', description: 'ID kategori yang ingin di-update' },
429
+ nama: { type: 'string', description: 'Nama kategori baru (opsional)' },
430
+ icon: { type: 'string', description: 'Icon baru (opsional)' },
431
+ aktif: { type: 'number', description: '1 = aktif, 0 = nonaktif (opsional)' },
432
+ },
433
+ required: ['id'],
434
+ },
435
+ },
436
+ },
437
+ {
438
+ type: 'function',
439
+ function: {
440
+ name: 'hapus_kategori',
441
+ description: 'Hapus kategori produk berdasarkan ID atau nama kategori.',
442
+ parameters: {
443
+ type: 'object',
444
+ properties: {
445
+ id: { type: 'number', description: 'ID kategori yang ingin dihapus (opsional jika nama diisi)' },
446
+ nama: { type: 'string', description: 'Nama kategori yang ingin dihapus (opsional jika id diisi)' },
447
+ },
448
+ required: [],
449
+ },
450
+ },
451
+ },
452
+
453
+ // ── Diskon & Promo ──
454
+ {
455
+ type: 'function',
456
+ function: {
457
+ name: 'daftar_diskon',
458
+ description: 'Lihat daftar semua diskon, promosi, atau voucher toko.',
459
+ parameters: {
460
+ type: 'object',
461
+ properties: {},
462
+ },
463
+ },
464
+ },
465
+ {
466
+ type: 'function',
467
+ function: {
468
+ name: 'tambah_diskon',
469
+ description: 'Buat promosi/diskon belanja baru.',
470
+ parameters: {
471
+ type: 'object',
472
+ properties: {
473
+ nama: { type: 'string', description: 'Nama promosi/diskon' },
474
+ tipe: {
475
+ type: 'string',
476
+ enum: ['total_persen', 'total_nominal', 'per_item_persen', 'per_item_nominal'],
477
+ description: 'Tipe diskon',
478
+ },
479
+ nilai: { type: 'number', description: 'Besar potongan (persentase/nominal Rupiah)' },
480
+ min_belanja: { type: 'number', description: 'Minimal belanja (opsional, default: 0)' },
481
+ tgl_mulai: { type: 'string', description: 'Tanggal mulai berlaku (format: YYYY-MM-DD)' },
482
+ tgl_selesai: { type: 'string', description: 'Tanggal berakhir promo (format: YYYY-MM-DD)' },
483
+ member_only: { type: 'number', enum: [0, 1], description: '1 = khusus member, 0 = umum. Default: 0' },
484
+ },
485
+ required: ['nama', 'tipe', 'nilai', 'tgl_mulai', 'tgl_selesai'],
486
+ },
487
+ },
488
+ },
489
+ {
490
+ type: 'function',
491
+ function: {
492
+ name: 'update_diskon',
493
+ description: 'Update data diskon/promosi berdasarkan ID.',
494
+ parameters: {
495
+ type: 'object',
496
+ properties: {
497
+ id: { type: 'number', description: 'ID diskon yang ingin diupdate' },
498
+ nama: { type: 'string', description: 'Nama promosi baru (opsional)' },
499
+ tipe: { type: 'string', enum: ['total_persen', 'total_nominal', 'per_item_persen', 'per_item_nominal'], description: 'Tipe diskon baru (opsional)' },
500
+ nilai: { type: 'number', description: 'Besar potongan baru (opsional)' },
501
+ min_belanja: { type: 'number', description: 'Minimal belanja baru (opsional)' },
502
+ tgl_mulai: { type: 'string', description: 'Tanggal mulai baru (opsional)' },
503
+ tgl_selesai: { type: 'string', description: 'Tanggal selesai baru (opsional)' },
504
+ is_aktif: { type: 'number', enum: [0, 1], description: '1 = aktif, 0 = nonaktif (opsional)' },
505
+ },
506
+ required: ['id'],
507
+ },
508
+ },
509
+ },
510
+ {
511
+ type: 'function',
512
+ function: {
513
+ name: 'hapus_diskon',
514
+ description: 'Hapus data diskon/promosi dari database berdasarkan ID.',
515
+ parameters: {
516
+ type: 'object',
517
+ properties: {
518
+ id: { type: 'number', description: 'ID diskon yang ingin dihapus' },
519
+ },
520
+ required: ['id'],
521
+ },
522
+ },
523
+ },
524
+
525
+ // ── Ekspor Laporan ──
526
+ {
527
+ type: 'function',
528
+ function: {
529
+ name: 'ekspor_laporan',
530
+ description: 'Ekspor laporan keuangan/stok toko ke dalam format CSV, Excel, atau PDF.',
531
+ parameters: {
532
+ type: 'object',
533
+ properties: {
534
+ jenis: {
535
+ type: 'string',
536
+ enum: ['penjualan', 'laba-rugi', 'mutasi-stok'],
537
+ description: 'Jenis laporan yang ingin diekspor',
538
+ },
539
+ format: {
540
+ type: 'string',
541
+ enum: ['csv', 'excel', 'pdf'],
542
+ description: 'Format output file (csv, excel, atau pdf)',
543
+ },
544
+ tgl_mulai: { type: 'string', description: 'Tanggal mulai periode (format: YYYY-MM-DD, default: hari ini)' },
545
+ tgl_selesai: { type: 'string', description: 'Tanggal selesai periode (format: YYYY-MM-DD, default: hari ini)' },
546
+ },
547
+ required: ['jenis', 'format'],
548
+ },
549
+ },
550
+ },
551
+
552
+ // ── Sistem ──
553
+ {
554
+ type: 'function',
555
+ function: {
556
+ name: 'statistik_toko',
557
+ description: 'Lihat statistik/ringkasan lengkap kondisi toko saat ini (total produk, stok, transaksi hari ini, member, piutang, dll).',
558
+ parameters: {
559
+ type: 'object',
560
+ properties: {},
561
+ },
562
+ },
563
+ },
564
+ ];
565
+
566
+ // ═══════════════════════════════════
567
+ // TOOL HANDLERS (eksekusi aksi)
568
+ // ═══════════════════════════════════
569
+
570
+ const formatRupiah = (n: number) => 'Rp ' + n.toLocaleString('id-ID');
571
+
572
+ export const TOOL_HANDLERS: Record<string, ToolHandler> = {
573
+ // ── Produk ──
574
+ cari_produk: {
575
+ nama: 'cari_produk',
576
+ butuhKonfirmasi: false,
577
+ adminOnly: false,
578
+ handler: async (params) => {
579
+ const { products, total } = await ProdukService.getProduk(0, 10, undefined, undefined, params.pencarian);
580
+ if (total === 0) return `Tidak ditemukan produk dengan pencarian "${params.pencarian}".`;
581
+
582
+ const items = products.map((p: any) => {
583
+ return `- [ID:${p.id}] ${p.nama} | Kode: ${p.kode} | Stok: ${Number(p.stok)} ${p.satuan} | Harga Jual: ${formatRupiah(Number(p.harga_jual))} | Kategori: ${p.kategori?.nama || '-'}`;
584
+ });
585
+ return `Ditemukan ${total} produk:\n${items.join('\n')}`;
586
+ },
587
+ },
588
+
589
+ detail_produk: {
590
+ nama: 'detail_produk',
591
+ butuhKonfirmasi: false,
592
+ adminOnly: false,
593
+ handler: async (params) => {
594
+ try {
595
+ const p: any = await ProdukService.getProdukById(params.id);
596
+ return [
597
+ `Detail Produk [ID:${p.id}]:`,
598
+ ` Nama: ${p.nama}`,
599
+ ` Kode: ${p.kode}`,
600
+ ` Barcode: ${p.barcode || '-'}`,
601
+ ` Kategori: ${p.kategori?.nama || '-'}`,
602
+ ` Supplier: ${p.supplier?.nama || '-'}`,
603
+ ` Satuan: ${p.satuan}`,
604
+ ` Harga Beli: ${formatRupiah(Number(p.harga_beli))}`,
605
+ ` Harga Jual: ${formatRupiah(Number(p.harga_jual))}`,
606
+ ` Margin: ${formatRupiah(Number(p.harga_jual) - Number(p.harga_beli))} (${(((Number(p.harga_jual) - Number(p.harga_beli)) / Number(p.harga_beli)) * 100).toFixed(1)}%)`,
607
+ ` Stok: ${Number(p.stok)} ${p.satuan}`,
608
+ ` Stok Minimum: ${Number(p.stok_min)}`,
609
+ ` Status: ${p.is_aktif ? 'Aktif' : 'Nonaktif'}`,
610
+ ` Expired: ${p.expired_date ? new Date(p.expired_date).toISOString().split('T')[0] : '-'}`,
611
+ p.harga_tingkat && p.harga_tingkat.length > 0
612
+ ? ` Harga Tingkat:\n${p.harga_tingkat.map((ht: any) => ` - ${ht.tingkat}: min ${Number(ht.min_qty)} → ${formatRupiah(Number(ht.harga))}`).join('\n')}`
613
+ : '',
614
+ ].filter(Boolean).join('\n');
615
+ } catch (err: any) {
616
+ return err.message || 'Produk tidak ditemukan.';
617
+ }
618
+ },
619
+ },
620
+
621
+ tambah_produk: {
622
+ nama: 'tambah_produk',
623
+ butuhKonfirmasi: true,
624
+ adminOnly: true,
625
+ handler: async (params, userId, ip, userAgent) => {
626
+ const kode = await generateKodeProduk();
627
+ const data = {
628
+ kode,
629
+ nama: params.nama,
630
+ harga_jual: Number(params.harga_jual),
631
+ harga_beli: Number(params.harga_beli),
632
+ kategori_id: Number(params.kategori_id),
633
+ satuan: params.satuan || 'pcs',
634
+ barcode: params.barcode || null,
635
+ stok_min: params.stok_min ? Number(params.stok_min) : 5,
636
+ stok: params.stok ? Number(params.stok) : 0,
637
+ };
638
+ try {
639
+ const produk = await ProdukService.create(data, userId, ip, userAgent);
640
+ return `Produk berhasil ditambahkan!\n ID: ${produk.id}\n Kode: ${produk.kode}\n Nama: ${produk.nama}\n Harga Jual: ${formatRupiah(Number(produk.harga_jual))}`;
641
+ } catch (err: any) {
642
+ return `Gagal menambah produk: ${err.message}`;
643
+ }
644
+ },
645
+ },
646
+
647
+ update_produk: {
648
+ nama: 'update_produk',
649
+ butuhKonfirmasi: true,
650
+ adminOnly: true,
651
+ handler: async (params, userId, ip, userAgent) => {
652
+ const { id, ...data } = params;
653
+ try {
654
+ const cleanData: any = { ...data };
655
+ if (cleanData.harga_jual !== undefined) cleanData.harga_jual = Number(cleanData.harga_jual);
656
+ if (cleanData.harga_beli !== undefined) cleanData.harga_beli = Number(cleanData.harga_beli);
657
+ if (cleanData.kategori_id !== undefined) cleanData.kategori_id = Number(cleanData.kategori_id);
658
+ if (cleanData.stok_min !== undefined) cleanData.stok_min = Number(cleanData.stok_min);
659
+ if (cleanData.stok !== undefined) cleanData.stok = Number(cleanData.stok);
660
+
661
+ const produk = await ProdukService.update(Number(id), cleanData, userId, ip, userAgent);
662
+ return `Produk berhasil diupdate!\n ID: ${produk.id}\n Nama: ${produk.nama}\n Harga Jual: ${formatRupiah(Number(produk.harga_jual))}`;
663
+ } catch (err: any) {
664
+ return `Gagal mengupdate produk: ${err.message}`;
665
+ }
666
+ },
667
+ },
668
+
669
+ hapus_produk: {
670
+ nama: 'hapus_produk',
671
+ butuhKonfirmasi: true,
672
+ adminOnly: true,
673
+ handler: async (params, userId, ip, userAgent) => {
674
+ try {
675
+ await ProdukService.delete(Number(params.id), userId, ip, userAgent);
676
+ return `Produk dengan ID ${params.id} berhasil dihapus.`;
677
+ } catch (err: any) {
678
+ return `Gagal menghapus produk: ${err.message}`;
679
+ }
680
+ },
681
+ },
682
+
683
+ // ── Stok ──
684
+ cek_stok: {
685
+ nama: 'cek_stok',
686
+ butuhKonfirmasi: false,
687
+ adminOnly: false,
688
+ handler: async (params) => {
689
+ const { products } = await ProdukService.getProduk(0, 5, undefined, undefined, params.pencarian);
690
+ if (products.length === 0) return `Tidak ditemukan produk "${params.pencarian}".`;
691
+
692
+ const items = products.map((p: any) => {
693
+ const stok = Number(p.stok);
694
+ const min = Number(p.stok_min);
695
+ let status = '✅ Aman';
696
+ if (stok === 0) status = '🔴 Habis';
697
+ else if (stok <= min) status = '⚠️ Rendah';
698
+ return `- ${p.nama}: ${stok} ${p.satuan} (min: ${min}) — ${status}`;
699
+ });
700
+ return `Stok produk:\n${items.join('\n')}`;
701
+ },
702
+ },
703
+
704
+ stok_rendah: {
705
+ nama: 'stok_rendah',
706
+ butuhKonfirmasi: false,
707
+ adminOnly: false,
708
+ handler: async () => {
709
+ const products = await prisma.$queryRaw<any[]>`
710
+ SELECT id, nama, stok, stok_min, satuan FROM produk
711
+ WHERE deleted_at IS NULL AND is_aktif = 1 AND stok <= stok_min
712
+ ORDER BY stok ASC
713
+ LIMIT 20
714
+ `;
715
+ if (products.length === 0) return 'Semua stok produk aman! Tidak ada yang rendah atau habis.';
716
+
717
+ const items = products.map((p: any) => {
718
+ const stok = Number(p.stok);
719
+ const icon = stok === 0 ? '🔴' : '⚠️';
720
+ return `${icon} [ID:${p.id}] ${p.nama}: ${stok} ${p.satuan} (min: ${Number(p.stok_min)})`;
721
+ });
722
+ return `Produk stok rendah/habis (${products.length}):\n${items.join('\n')}`;
723
+ },
724
+ },
725
+
726
+ stok_kadaluarsa: {
727
+ nama: 'stok_kadaluarsa',
728
+ butuhKonfirmasi: false,
729
+ adminOnly: false,
730
+ handler: async () => {
731
+ const hariIni = new Date();
732
+ const batas = new Date();
733
+ batas.setDate(batas.getDate() + 30);
734
+
735
+ const expired = await prisma.produk.findMany({
736
+ where: { deleted_at: null, is_aktif: 1, expired_date: { lt: hariIni } },
737
+ select: { id: true, nama: true, expired_date: true, stok: true },
738
+ take: 10,
739
+ });
740
+
741
+ const nearExpired = await prisma.produk.findMany({
742
+ where: { deleted_at: null, is_aktif: 1, expired_date: { gte: hariIni, lte: batas } },
743
+ select: { id: true, nama: true, expired_date: true, stok: true },
744
+ take: 10,
745
+ });
746
+
747
+ const lines: string[] = [];
748
+ if (expired.length > 0) {
749
+ lines.push('🔴 Sudah Kadaluarsa:');
750
+ expired.forEach((p) => {
751
+ lines.push(` - [ID:${p.id}] ${p.nama} — Expired: ${p.expired_date ? new Date(p.expired_date).toISOString().split('T')[0] : '-'} | Stok: ${Number(p.stok)}`);
752
+ });
753
+ }
754
+ if (nearExpired.length > 0) {
755
+ lines.push('⚠️ Mendekati Kadaluarsa (30 hari):');
756
+ nearExpired.forEach((p) => {
757
+ lines.push(` - [ID:${p.id}] ${p.nama} — Expired: ${p.expired_date ? new Date(p.expired_date).toISOString().split('T')[0] : '-'} | Stok: ${Number(p.stok)}`);
758
+ });
759
+ }
760
+ if (lines.length === 0) return 'Tidak ada produk yang kadaluarsa atau mendekati kadaluarsa.';
761
+ return lines.join('\n');
762
+ },
763
+ },
764
+
765
+ tambah_stok_masuk: {
766
+ nama: 'tambah_stok_masuk',
767
+ butuhKonfirmasi: true,
768
+ adminOnly: true,
769
+ handler: async (params, userId) => {
770
+ try {
771
+ let produkId = params.produk_id ? Number(params.produk_id) : null;
772
+ if (!produkId && params.nama_produk) {
773
+ const prod = await prisma.produk.findFirst({
774
+ where: { nama: { contains: params.nama_produk }, deleted_at: null },
775
+ });
776
+ if (prod) {
777
+ produkId = prod.id;
778
+ }
779
+ }
780
+
781
+ if (!produkId) {
782
+ return `Gagal menambah stok: Produk tidak ditemukan di database.`;
783
+ }
784
+
785
+ const result = await StokService.tambahStok({
786
+ produkId,
787
+ qty: Number(params.qty),
788
+ hargaBeli: Number(params.harga_beli),
789
+ supplierId: params.supplier_id ? Number(params.supplier_id) : null,
790
+ userId,
791
+ catatan: params.catatan || 'Restock via chatbot',
792
+ referensi: 'CHATBOT',
793
+ });
794
+ return `Stok berhasil ditambahkan!\n Batch: ${result.batch.id}\n Qty: ${params.qty}\n Harga Beli: ${formatRupiah(params.harga_beli)}`;
795
+ } catch (err: any) {
796
+ return `Gagal menambah stok: ${err.message}`;
797
+ }
798
+ },
799
+ },
800
+
801
+ // ── Transaksi ──
802
+ ringkasan_penjualan: {
803
+ nama: 'ringkasan_penjualan',
804
+ butuhKonfirmasi: false,
805
+ adminOnly: false,
806
+ handler: async (params) => {
807
+ const now = new Date();
808
+ let tanggalMulai: Date;
809
+ let tanggalAkhir: Date = new Date(now);
810
+ tanggalAkhir.setHours(23, 59, 59, 999);
811
+
812
+ switch (params.periode) {
813
+ case 'kemarin': {
814
+ tanggalMulai = new Date(now);
815
+ tanggalMulai.setDate(tanggalMulai.getDate() - 1);
816
+ tanggalMulai.setHours(0, 0, 0, 0);
817
+ tanggalAkhir = new Date(tanggalMulai);
818
+ tanggalAkhir.setHours(23, 59, 59, 999);
819
+ break;
820
+ }
821
+ case 'minggu_ini': {
822
+ tanggalMulai = new Date(now);
823
+ const day = tanggalMulai.getDay();
824
+ tanggalMulai.setDate(tanggalMulai.getDate() - (day === 0 ? 6 : day - 1));
825
+ tanggalMulai.setHours(0, 0, 0, 0);
826
+ break;
827
+ }
828
+ case 'bulan_ini': {
829
+ tanggalMulai = new Date(now.getFullYear(), now.getMonth(), 1);
830
+ break;
831
+ }
832
+ default: { // hari_ini
833
+ tanggalMulai = new Date(now);
834
+ tanggalMulai.setHours(0, 0, 0, 0);
835
+ break;
836
+ }
837
+ }
838
+
839
+ const result = await prisma.transaksi.aggregate({
840
+ where: {
841
+ tanggal: { gte: tanggalMulai, lte: tanggalAkhir },
842
+ deleted_at: null,
843
+ status: { not: 'void' },
844
+ },
845
+ _count: { id: true },
846
+ _sum: { total: true, diskon_total: true },
847
+ });
848
+
849
+ const jumlah = result._count.id || 0;
850
+ const total = Number(result._sum.total || 0);
851
+ const diskon = Number(result._sum.diskon_total || 0);
852
+
853
+ const periodeLabel: Record<string, string> = {
854
+ hari_ini: 'Hari Ini',
855
+ kemarin: 'Kemarin',
856
+ minggu_ini: 'Minggu Ini',
857
+ bulan_ini: 'Bulan Ini',
858
+ };
859
+
860
+ return [
861
+ `Ringkasan Penjualan — ${periodeLabel[params.periode] || params.periode}:`,
862
+ ` Jumlah Transaksi: ${jumlah}`,
863
+ ` Total Penjualan: ${formatRupiah(total)}`,
864
+ ` Total Diskon: ${formatRupiah(diskon)}`,
865
+ ` Rata-rata per Transaksi: ${jumlah > 0 ? formatRupiah(Math.round(total / jumlah)) : 'Rp 0'}`,
866
+ ].join('\n');
867
+ },
868
+ },
869
+
870
+ detail_transaksi: {
871
+ nama: 'detail_transaksi',
872
+ butuhKonfirmasi: false,
873
+ adminOnly: false,
874
+ handler: async (params) => {
875
+ const tx = await prisma.transaksi.findUnique({
876
+ where: { no_transaksi: params.no_transaksi },
877
+ include: {
878
+ transaksi_detail: { include: { produk: true } },
879
+ pembayaran: true,
880
+ user: { select: { nama: true } },
881
+ member: { select: { nama: true, kode: true } },
882
+ },
883
+ });
884
+ if (!tx) return `Transaksi dengan nomor "${params.no_transaksi}" tidak ditemukan.`;
885
+
886
+ const items = tx.transaksi_detail.map((d: any) =>
887
+ ` - ${d.nama_produk} x${Number(d.qty)} ${d.satuan} @ ${formatRupiah(Number(d.harga_satuan))} = ${formatRupiah(Number(d.subtotal))}`
888
+ );
889
+
890
+ const bayar = tx.pembayaran.map((p: any) =>
891
+ ` - ${p.metode}: ${formatRupiah(Number(p.nominal))}`
892
+ );
893
+
894
+ return [
895
+ `Detail Transaksi ${tx.no_transaksi}:`,
896
+ ` Tanggal: ${tx.tanggal}`,
897
+ ` Kasir: ${tx.user.nama}`,
898
+ ` Member: ${tx.member ? `${tx.member.nama} (${tx.member.kode})` : '-'}`,
899
+ ` Status: ${tx.status}`,
900
+ ``,
901
+ ` Items:`,
902
+ ...items,
903
+ ``,
904
+ ` Subtotal: ${formatRupiah(Number(tx.subtotal))}`,
905
+ ` Diskon: ${formatRupiah(Number(tx.diskon_total))}`,
906
+ ` Total: ${formatRupiah(Number(tx.total))}`,
907
+ ``,
908
+ ` Pembayaran:`,
909
+ ...bayar,
910
+ ` Kembalian: ${formatRupiah(Number(tx.kembalian))}`,
911
+ ].join('\n');
912
+ },
913
+ },
914
+
915
+ void_transaksi: {
916
+ nama: 'void_transaksi',
917
+ butuhKonfirmasi: true,
918
+ adminOnly: true,
919
+ handler: async (params, userId, ip, userAgent) => {
920
+ try {
921
+ let txId = params.id ? Number(params.id) : null;
922
+
923
+ if (!txId && params.no_transaksi) {
924
+ const tx = await prisma.transaksi.findUnique({
925
+ where: { no_transaksi: params.no_transaksi }
926
+ });
927
+ if (tx) txId = tx.id;
928
+ }
929
+
930
+ if (!txId) {
931
+ return `Gagal membatalkan transaksi: Nomor atau ID transaksi tidak ditemukan.`;
932
+ }
933
+
934
+ await TransaksiService.prosesVoidTransaksi(
935
+ txId,
936
+ params.alasan || 'Pembatalan via chatbot',
937
+ userId,
938
+ ip,
939
+ userAgent
940
+ );
941
+
942
+ return `Transaksi dengan ID ${txId} (${params.no_transaksi || ''}) berhasil dibatalkan (void).`;
943
+ } catch (err: any) {
944
+ return `Gagal membatalkan transaksi: ${err.message}`;
945
+ }
946
+ },
947
+ },
948
+
949
+ // ── Member ──
950
+ cari_member: {
951
+ nama: 'cari_member',
952
+ butuhKonfirmasi: false,
953
+ adminOnly: false,
954
+ handler: async (params) => {
955
+ const members = await MemberService.getMembers(0, 10, params.pencarian);
956
+ if (members.length === 0) return `Tidak ditemukan member dengan pencarian "${params.pencarian}".`;
957
+
958
+ const items = members.map((m: any) =>
959
+ `- [ID:${m.id}] ${m.nama} | Kode: ${m.kode} | HP: ${m.nomor_hp} | Poin: ${m.total_poin} | Bon: ${formatRupiah(Number(m.total_bon))}`
960
+ );
961
+ return `Ditemukan ${members.length} member:\n${items.join('\n')}`;
962
+ },
963
+ },
964
+
965
+ tambah_member: {
966
+ nama: 'tambah_member',
967
+ butuhKonfirmasi: true,
968
+ adminOnly: true,
969
+ handler: async (params, userId, ip, userAgent) => {
970
+ const kode = await generateKodeMember();
971
+ try {
972
+ const member = await MemberService.create({
973
+ kode,
974
+ nama: params.nama,
975
+ nomor_hp: params.nomor_hp,
976
+ alamat: params.alamat || null,
977
+ limit_bon: params.limit_bon ? Number(params.limit_bon) : 0,
978
+ }, userId, ip, userAgent);
979
+ return `Member berhasil didaftarkan!\n ID: ${member.id}\n Kode: ${member.kode}\n Nama: ${member.nama}\n HP: ${member.nomor_hp}`;
980
+ } catch (err: any) {
981
+ return `Gagal mendaftarkan member: ${err.message}`;
982
+ }
983
+ },
984
+ },
985
+
986
+ update_member: {
987
+ nama: 'update_member',
988
+ butuhKonfirmasi: true,
989
+ adminOnly: true,
990
+ handler: async (params, userId, ip, userAgent) => {
991
+ const { id, ...data } = params;
992
+ try {
993
+ const cleanData: any = { ...data };
994
+ if (cleanData.limit_bon !== undefined) cleanData.limit_bon = Number(cleanData.limit_bon);
995
+
996
+ const member = await MemberService.update(Number(id), cleanData, userId, ip, userAgent);
997
+ return `Member berhasil diupdate!\n ID: ${member.id}\n Nama: ${member.nama}\n Limit Bon: ${formatRupiah(Number(member.limit_bon))}`;
998
+ } catch (err: any) {
999
+ return `Gagal mengupdate member: ${err.message}`;
1000
+ }
1001
+ },
1002
+ },
1003
+
1004
+ hapus_member: {
1005
+ nama: 'hapus_member',
1006
+ butuhKonfirmasi: true,
1007
+ adminOnly: true,
1008
+ handler: async (params, userId, ip, userAgent) => {
1009
+ try {
1010
+ await MemberService.delete(Number(params.id), userId, ip, userAgent);
1011
+ return `Member dengan ID ${params.id} berhasil dihapus.`;
1012
+ } catch (err: any) {
1013
+ return `Gagal menghapus member: ${err.message}`;
1014
+ }
1015
+ },
1016
+ },
1017
+
1018
+ info_bon_member: {
1019
+ nama: 'info_bon_member',
1020
+ butuhKonfirmasi: false,
1021
+ adminOnly: false,
1022
+ handler: async (params) => {
1023
+ const bonList = await BonService.getBon(0, 10, undefined, params.member_id);
1024
+ if (bonList.length === 0) return `Member dengan ID ${params.member_id} tidak memiliki bon/piutang.`;
1025
+
1026
+ const member = (bonList[0] as any).member;
1027
+ const lines = [
1028
+ `Info Bon/Piutang — ${member.nama} (${member.kode}):`,
1029
+ ` Total Bon Aktif: ${formatRupiah(Number(member.total_bon))}`,
1030
+ ` Limit Bon: ${formatRupiah(Number(member.limit_bon))}`,
1031
+ ` Sisa Limit: ${formatRupiah(Number(member.limit_bon) - Number(member.total_bon))}`,
1032
+ ``,
1033
+ ` Riwayat Bon:`,
1034
+ ];
1035
+ bonList.forEach((b: any) => {
1036
+ lines.push(` - Bon #${b.id} | ${formatRupiah(Number(b.total_bon))} | Sisa: ${formatRupiah(Number(b.sisa_bon))} | Status: ${b.status} | Jatuh Tempo: ${b.jatuh_tempo ? new Date(b.jatuh_tempo).toISOString().split('T')[0] : '-'}`);
1037
+ });
1038
+ return lines.join('\n');
1039
+ },
1040
+ },
1041
+
1042
+ // ── Supplier ──
1043
+ cari_supplier: {
1044
+ nama: 'cari_supplier',
1045
+ butuhKonfirmasi: false,
1046
+ adminOnly: false,
1047
+ handler: async (params) => {
1048
+ const suppliers = await SupplierService.getSuppliers(0, 10, params.pencarian);
1049
+ if (suppliers.length === 0) return `Tidak ditemukan supplier dengan pencarian "${params.pencarian}".`;
1050
+
1051
+ const items = suppliers.map((s: any) =>
1052
+ `- [ID:${s.id}] ${s.nama} | HP: ${s.nomor_hp || '-'} | Kontak: ${s.kontak || '-'} | Hutang: ${formatRupiah(Number(s.hutang))}`
1053
+ );
1054
+ return `Ditemukan ${suppliers.length} supplier:\n${items.join('\n')}`;
1055
+ },
1056
+ },
1057
+
1058
+ tambah_supplier: {
1059
+ nama: 'tambah_supplier',
1060
+ butuhKonfirmasi: true,
1061
+ adminOnly: true,
1062
+ handler: async (params, userId, ip, userAgent) => {
1063
+ try {
1064
+ const supplier = await SupplierService.create({
1065
+ nama: params.nama,
1066
+ kontak: params.kontak || null,
1067
+ nomor_hp: params.nomor_hp || null,
1068
+ alamat: params.alamat || null,
1069
+ }, userId, ip, userAgent);
1070
+ return `Supplier berhasil ditambahkan!\n ID: ${supplier.id}\n Nama: ${supplier.nama}`;
1071
+ } catch (err: any) {
1072
+ return `Gagal menambah supplier: ${err.message}`;
1073
+ }
1074
+ },
1075
+ },
1076
+
1077
+ update_supplier: {
1078
+ nama: 'update_supplier',
1079
+ butuhKonfirmasi: true,
1080
+ adminOnly: true,
1081
+ handler: async (params, userId, ip, userAgent) => {
1082
+ const { id, ...data } = params;
1083
+ try {
1084
+ const cleanData: any = { ...data };
1085
+ const supplier = await SupplierService.update(Number(id), cleanData, userId, ip, userAgent);
1086
+ return `Supplier berhasil diupdate!\n ID: ${supplier.id}\n Nama: ${supplier.nama}`;
1087
+ } catch (err: any) {
1088
+ return `Gagal mengupdate supplier: ${err.message}`;
1089
+ }
1090
+ },
1091
+ },
1092
+
1093
+ hapus_supplier: {
1094
+ nama: 'hapus_supplier',
1095
+ butuhKonfirmasi: true,
1096
+ adminOnly: true,
1097
+ handler: async (params, userId, ip, userAgent) => {
1098
+ try {
1099
+ await SupplierService.delete(Number(params.id), userId, ip, userAgent);
1100
+ return `Supplier dengan ID ${params.id} berhasil dihapus.`;
1101
+ } catch (err: any) {
1102
+ return `Gagal menghapus supplier: ${err.message}`;
1103
+ }
1104
+ },
1105
+ },
1106
+
1107
+ // ── Laporan ──
1108
+ produk_terlaris: {
1109
+ nama: 'produk_terlaris',
1110
+ butuhKonfirmasi: false,
1111
+ adminOnly: false,
1112
+ handler: async (params) => {
1113
+ const limit = params.limit || 10;
1114
+ const now = new Date();
1115
+ let dateFilter = '';
1116
+
1117
+ if (params.periode === 'minggu_ini') {
1118
+ const start = new Date(now);
1119
+ const day = start.getDay();
1120
+ start.setDate(start.getDate() - (day === 0 ? 6 : day - 1));
1121
+ dateFilter = `AND t.tanggal >= '${start.toISOString().split('T')[0]}'`;
1122
+ } else if (params.periode !== 'semua') {
1123
+ // bulan_ini (default)
1124
+ const start = new Date(now.getFullYear(), now.getMonth(), 1);
1125
+ dateFilter = `AND t.tanggal >= '${start.toISOString().split('T')[0]}'`;
1126
+ }
1127
+
1128
+ const results = await prisma.$queryRawUnsafe<any[]>(`
1129
+ SELECT td.nama_produk, SUM(td.qty) as total_qty, SUM(td.subtotal) as total_revenue
1130
+ FROM transaksi_detail td
1131
+ JOIN transaksi t ON t.id = td.transaksi_id
1132
+ WHERE t.deleted_at IS NULL AND t.status != 'void' ${dateFilter}
1133
+ GROUP BY td.produk_id, td.nama_produk
1134
+ ORDER BY total_qty DESC
1135
+ LIMIT ${limit}
1136
+ `);
1137
+
1138
+ if (results.length === 0) return 'Belum ada data penjualan untuk periode ini.';
1139
+
1140
+ const periodeLabel: Record<string, string> = {
1141
+ minggu_ini: 'Minggu Ini',
1142
+ bulan_ini: 'Bulan Ini',
1143
+ semua: 'Semua Waktu',
1144
+ };
1145
+
1146
+ const lines = [`Produk Terlaris — ${periodeLabel[params.periode || 'bulan_ini'] || 'Bulan Ini'}:`];
1147
+ results.forEach((r: any, i: number) => {
1148
+ lines.push(` ${i + 1}. ${r.nama_produk} — ${Number(r.total_qty)} terjual | Revenue: ${formatRupiah(Number(r.total_revenue))}`);
1149
+ });
1150
+ return lines.join('\n');
1151
+ },
1152
+ },
1153
+
1154
+ // ── Kategori ──
1155
+ daftar_kategori: {
1156
+ nama: 'daftar_kategori',
1157
+ butuhKonfirmasi: false,
1158
+ adminOnly: false,
1159
+ handler: async () => {
1160
+ const categories = await KategoriService.getKategori(0, 100);
1161
+ if (categories.length === 0) return 'Belum ada kategori produk.';
1162
+
1163
+ const items = categories.map((k: any) =>
1164
+ `- [ID:${k.id}] ${k.nama} ${k.icon ? `(${k.icon})` : ''}`
1165
+ );
1166
+ return `Daftar Kategori (${categories.length}):\n${items.join('\n')}`;
1167
+ },
1168
+ },
1169
+
1170
+ // ── Sistem ──
1171
+ statistik_toko: {
1172
+ nama: 'statistik_toko',
1173
+ butuhKonfirmasi: false,
1174
+ adminOnly: false,
1175
+ handler: async () => {
1176
+ const hariIni = new Date();
1177
+ hariIni.setHours(0, 0, 0, 0);
1178
+ const besok = new Date(hariIni);
1179
+ besok.setDate(besok.getDate() + 1);
1180
+
1181
+ const [totalProduk, totalMember, totalSupplier, totalKategori, txHariIni, bonAktif, shiftAktif, stokRendah] =
1182
+ await Promise.all([
1183
+ prisma.produk.count({ where: { deleted_at: null, is_aktif: 1 } }),
1184
+ prisma.member.count({ where: { deleted_at: null, aktif: 1 } }),
1185
+ prisma.supplier.count({ where: { deleted_at: null, aktif: 1 } }),
1186
+ prisma.kategori.count({ where: { deleted_at: null, aktif: 1 } }),
1187
+ prisma.transaksi.aggregate({
1188
+ where: { tanggal: { gte: hariIni, lt: besok }, deleted_at: null, status: { not: 'void' } },
1189
+ _count: { id: true },
1190
+ _sum: { total: true },
1191
+ }),
1192
+ prisma.bon.aggregate({ where: { status: 'aktif' }, _sum: { sisa_bon: true }, _count: { id: true } }),
1193
+ prisma.shift.findFirst({ where: { status: 'buka' }, include: { user: { select: { nama: true } } } }),
1194
+ prisma.$queryRaw<any[]>`SELECT COUNT(*) as count FROM produk WHERE deleted_at IS NULL AND is_aktif = 1 AND stok <= stok_min`,
1195
+ ]);
1196
+
1197
+ return [
1198
+ '📊 Statistik Toko Tokiva:',
1199
+ '',
1200
+ `📦 Produk Aktif: ${totalProduk}`,
1201
+ `📂 Kategori: ${totalKategori}`,
1202
+ `👥 Member: ${totalMember}`,
1203
+ `🚚 Supplier: ${totalSupplier}`,
1204
+ '',
1205
+ `💰 Transaksi Hari Ini: ${txHariIni._count.id || 0} transaksi`,
1206
+ `💵 Revenue Hari Ini: ${formatRupiah(Number(txHariIni._sum.total || 0))}`,
1207
+ '',
1208
+ `📋 Bon Aktif: ${bonAktif._count.id || 0} bon`,
1209
+ `💳 Total Piutang: ${formatRupiah(Number(bonAktif._sum.sisa_bon || 0))}`,
1210
+ '',
1211
+ `⚠️ Produk Stok Rendah/Habis: ${Number((stokRendah as any)[0]?.count || 0)}`,
1212
+ `🕐 Shift Aktif: ${shiftAktif ? `${shiftAktif.user.nama}` : 'Tidak ada'}`,
1213
+ ].join('\n');
1214
+ },
1215
+ },
1216
+
1217
+ tambah_kategori: {
1218
+ nama: 'tambah_kategori',
1219
+ butuhKonfirmasi: true,
1220
+ adminOnly: true,
1221
+ handler: async (params, userId, ip, userAgent) => {
1222
+ try {
1223
+ const result = await KategoriService.create(params, userId, ip, userAgent);
1224
+ return `Kategori "${result.nama}" berhasil dibuat dengan ID ${result.id}.`;
1225
+ } catch (err: any) {
1226
+ return `Gagal menambah kategori: ${err.message}`;
1227
+ }
1228
+ },
1229
+ },
1230
+
1231
+ update_kategori: {
1232
+ nama: 'update_kategori',
1233
+ butuhKonfirmasi: true,
1234
+ adminOnly: true,
1235
+ handler: async (params, userId, ip, userAgent) => {
1236
+ const { id, ...data } = params;
1237
+ try {
1238
+ const result = await KategoriService.update(Number(id), data, userId, ip, userAgent);
1239
+ return `Kategori ID ${id} berhasil diperbarui (Nama: ${result.nama}).`;
1240
+ } catch (err: any) {
1241
+ return `Gagal memperbarui kategori: ${err.message}`;
1242
+ }
1243
+ },
1244
+ },
1245
+
1246
+ hapus_kategori: {
1247
+ nama: 'hapus_kategori',
1248
+ butuhKonfirmasi: true,
1249
+ adminOnly: true,
1250
+ handler: async (params, userId, ip, userAgent) => {
1251
+ try {
1252
+ let idVal = params.id ? Number(params.id) : null;
1253
+ if (!idVal && params.nama) {
1254
+ const cat = await prisma.kategori.findFirst({
1255
+ where: { nama: params.nama, deleted_at: null },
1256
+ });
1257
+ if (cat) {
1258
+ idVal = cat.id;
1259
+ }
1260
+ }
1261
+
1262
+ if (!idVal) {
1263
+ return `Gagal menghapus kategori: ID atau nama kategori tidak valid atau tidak ditemukan di database.`;
1264
+ }
1265
+
1266
+ await KategoriService.delete(idVal, userId, ip, userAgent);
1267
+ return `Kategori "${params.nama || idVal}" berhasil dihapus.`;
1268
+ } catch (err: any) {
1269
+ return `Gagal menghapus kategori: ${err.message}`;
1270
+ }
1271
+ },
1272
+ },
1273
+
1274
+ daftar_diskon: {
1275
+ nama: 'daftar_diskon',
1276
+ butuhKonfirmasi: false,
1277
+ adminOnly: false,
1278
+ handler: async () => {
1279
+ const promos = await prisma.diskon.findMany({
1280
+ orderBy: { created_at: 'desc' },
1281
+ });
1282
+ if (promos.length === 0) return 'Belum ada promosi atau diskon belanja yang terdaftar.';
1283
+
1284
+ const formatTipe = (t: string) => {
1285
+ const map: Record<string, string> = {
1286
+ total_persen: 'Potongan Belanja (%)',
1287
+ total_nominal: 'Potongan Belanja (Rupiah)',
1288
+ per_item_persen: 'Potongan Produk (%)',
1289
+ per_item_nominal: 'Potongan Produk (Rupiah)',
1290
+ };
1291
+ return map[t] || t;
1292
+ };
1293
+
1294
+ const lines = promos.map((p) => {
1295
+ const status = p.is_aktif ? '✅ Aktif' : '❌ Nonaktif';
1296
+ const tglMulaiStr = p.tgl_mulai ? new Date(p.tgl_mulai).toISOString().split('T')[0] : '';
1297
+ const tglSelesaiStr = p.tgl_selesai ? new Date(p.tgl_selesai).toISOString().split('T')[0] : '';
1298
+ const nilaiStr = p.tipe.includes('persen') ? `${p.nilai}%` : formatRupiah(Number(p.nilai));
1299
+ return `- [ID:${p.id}] ${p.nama} | Tipe: ${formatTipe(p.tipe)} | Nilai: ${nilaiStr} | Min Belanja: ${formatRupiah(Number(p.min_belanja))} | Berlaku: ${tglMulaiStr} s/d ${tglSelesaiStr} | Status: ${status}`;
1300
+ });
1301
+
1302
+ return `Daftar Promosi/Diskon Toko:\n${lines.join('\n')}`;
1303
+ },
1304
+ },
1305
+
1306
+ tambah_diskon: {
1307
+ nama: 'tambah_diskon',
1308
+ butuhKonfirmasi: true,
1309
+ adminOnly: true,
1310
+ handler: async (params, userId, ip, userAgent) => {
1311
+ try {
1312
+ const promo = await prisma.diskon.create({
1313
+ data: {
1314
+ nama: params.nama,
1315
+ tipe: params.tipe,
1316
+ nilai: Number(params.nilai),
1317
+ min_belanja: params.min_belanja !== undefined ? Number(params.min_belanja) : 0,
1318
+ tgl_mulai: new Date(params.tgl_mulai),
1319
+ tgl_selesai: new Date(params.tgl_selesai),
1320
+ member_only: params.member_only !== undefined ? Number(params.member_only) : 0,
1321
+ is_aktif: 1,
1322
+ },
1323
+ });
1324
+
1325
+ await catatAudit({
1326
+ userId,
1327
+ aksi: 'create',
1328
+ entitas: 'diskon',
1329
+ entitasId: promo.id,
1330
+ dataBaru: { nama: promo.nama, tipe: promo.tipe, nilai: Number(promo.nilai) },
1331
+ ipAddress: ip,
1332
+ userAgent,
1333
+ });
1334
+
1335
+ return `Diskon/Promosi "${promo.nama}" berhasil dibuat dengan ID ${promo.id}.`;
1336
+ } catch (err: any) {
1337
+ return `Gagal membuat promosi: ${err.message}`;
1338
+ }
1339
+ },
1340
+ },
1341
+
1342
+ update_diskon: {
1343
+ nama: 'update_diskon',
1344
+ butuhKonfirmasi: true,
1345
+ adminOnly: true,
1346
+ handler: async (params, userId, ip, userAgent) => {
1347
+ const { id, ...data } = params;
1348
+ const idNum = Number(id);
1349
+ try {
1350
+ const existing = await prisma.diskon.findUnique({ where: { id: idNum } });
1351
+ if (!existing) return `Diskon dengan ID ${id} tidak ditemukan.`;
1352
+
1353
+ const updateData: any = {};
1354
+ if (data.nama !== undefined) updateData.nama = data.nama;
1355
+ if (data.tipe !== undefined) updateData.tipe = data.tipe;
1356
+ if (data.nilai !== undefined) updateData.nilai = Number(data.nilai);
1357
+ if (data.min_belanja !== undefined) updateData.min_belanja = Number(data.min_belanja);
1358
+ if (data.tgl_mulai !== undefined) updateData.tgl_mulai = new Date(data.tgl_mulai);
1359
+ if (data.tgl_selesai !== undefined) updateData.tgl_selesai = new Date(data.tgl_selesai);
1360
+ if (data.is_aktif !== undefined) updateData.is_aktif = Number(data.is_aktif);
1361
+
1362
+ const promo = await prisma.diskon.update({
1363
+ where: { id: idNum },
1364
+ data: updateData,
1365
+ });
1366
+
1367
+ await catatAudit({
1368
+ userId,
1369
+ aksi: 'update',
1370
+ entitas: 'diskon',
1371
+ entitasId: promo.id,
1372
+ dataLama: { nama: existing.nama, tipe: existing.tipe, nilai: Number(existing.nilai) },
1373
+ dataBaru: { nama: promo.nama, tipe: promo.tipe, nilai: Number(promo.nilai) },
1374
+ ipAddress: ip,
1375
+ userAgent,
1376
+ });
1377
+
1378
+ return `Diskon ID ${id} berhasil diperbarui (Nama: ${promo.nama}).`;
1379
+ } catch (err: any) {
1380
+ return `Gagal memperbarui diskon: ${err.message}`;
1381
+ }
1382
+ },
1383
+ },
1384
+
1385
+ hapus_diskon: {
1386
+ nama: 'hapus_diskon',
1387
+ butuhKonfirmasi: true,
1388
+ adminOnly: true,
1389
+ handler: async (params, userId, ip, userAgent) => {
1390
+ const idNum = Number(params.id);
1391
+ try {
1392
+ const existing = await prisma.diskon.findUnique({ where: { id: idNum } });
1393
+ if (!existing) return `Diskon dengan ID ${params.id} tidak ditemukan.`;
1394
+
1395
+ await prisma.diskon.delete({ where: { id: idNum } });
1396
+
1397
+ await catatAudit({
1398
+ userId,
1399
+ aksi: 'delete',
1400
+ entitas: 'diskon',
1401
+ entitasId: idNum,
1402
+ dataLama: { nama: existing.nama, tipe: existing.tipe, nilai: Number(existing.nilai) },
1403
+ dataBaru: { deleted: true },
1404
+ ipAddress: ip,
1405
+ userAgent,
1406
+ });
1407
+
1408
+ return `Diskon dengan ID ${params.id} berhasil dihapus dari database.`;
1409
+ } catch (err: any) {
1410
+ return `Gagal menghapus diskon: ${err.message}`;
1411
+ }
1412
+ },
1413
+ },
1414
+
1415
+ ekspor_laporan: {
1416
+ nama: 'ekspor_laporan',
1417
+ butuhKonfirmasi: false,
1418
+ adminOnly: false,
1419
+ handler: async (params) => {
1420
+ try {
1421
+ const tglMulai = params.tgl_mulai ? new Date(params.tgl_mulai) : new Date();
1422
+ const tglSelesai = params.tgl_selesai ? new Date(params.tgl_selesai) : new Date();
1423
+
1424
+ if (!params.tgl_mulai) tglMulai.setHours(0,0,0,0);
1425
+ if (!params.tgl_selesai) tglSelesai.setHours(23,59,59,999);
1426
+
1427
+ const result = await OpsService.eksporLaporan({
1428
+ jenis: params.jenis,
1429
+ format: params.format,
1430
+ tglMulai,
1431
+ tglSelesai,
1432
+ });
1433
+
1434
+ return `Laporan ${params.jenis} (${params.format.toUpperCase()}) berhasil dibuat!\nSilakan download melalui tautan berikut:\n👉 ${result.url}`;
1435
+ } catch (err: any) {
1436
+ return `Gagal mengekspor laporan: ${err.message}`;
1437
+ }
1438
+ },
1439
+ },
1440
+ };
1441
+
1442
+ /**
1443
+ * Ambil tools yang boleh diakses berdasarkan role user.
1444
+ */
1445
+ export function getToolsForRole(role: string): ChatCompletionTool[] {
1446
+ if (role === 'admin') return TOOL_DEFINITIONS;
1447
+
1448
+ // Kasir hanya bisa akses tools non-admin
1449
+ const adminOnlyTools = Object.values(TOOL_HANDLERS)
1450
+ .filter((h) => h.adminOnly)
1451
+ .map((h) => h.nama);
1452
+
1453
+ return TOOL_DEFINITIONS.filter(
1454
+ (t) => !adminOnlyTools.includes((t as any).function.name)
1455
+ );
1456
+ }
src/utils/supabase.ts CHANGED
@@ -70,6 +70,38 @@ export const uploadBase64ToSupabase = async (
70
  }
71
  };
72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  export const deleteFromSupabase = async (
74
  bucket: string,
75
  filePath: string
 
70
  }
71
  };
72
 
73
+ export const uploadBufferToSupabase = async (
74
+ bucket: string,
75
+ filePath: string,
76
+ fileBuffer: Buffer,
77
+ mimeType: string
78
+ ): Promise<string | null> => {
79
+ try {
80
+ const uploadUrl = `${SUPABASE_URL}/storage/v1/object/${bucket}/${filePath}`;
81
+
82
+ const response = await fetch(uploadUrl, {
83
+ method: 'POST',
84
+ headers: {
85
+ 'Authorization': `Bearer ${SUPABASE_SERVICE_KEY}`,
86
+ 'Content-Type': mimeType,
87
+ 'x-upsert': 'true'
88
+ },
89
+ body: fileBuffer
90
+ });
91
+
92
+ if (response.ok) {
93
+ return `${SUPABASE_URL}/storage/v1/object/public/${bucket}/${filePath}`;
94
+ } else {
95
+ const errorText = await response.text();
96
+ console.error(`Supabase upload failed: ${response.status} - ${errorText}`);
97
+ return null;
98
+ }
99
+ } catch (error) {
100
+ console.error('Error uploading buffer to Supabase:', error);
101
+ return null;
102
+ }
103
+ };
104
+
105
  export const deleteFromSupabase = async (
106
  bucket: string,
107
  filePath: string