Spaces:
Sleeping
Sleeping
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 +3 -0
- package-lock.json +226 -3
- package.json +3 -0
- src/app.ts +9 -0
- src/controllers/bon.controller.ts +55 -0
- src/controllers/chatbot.controller.ts +92 -0
- src/controllers/diskon.controller.ts +199 -0
- src/controllers/ops.controller.ts +27 -0
- src/routes/bon.route.ts +3 -1
- src/routes/chatbot.route.ts +25 -0
- src/routes/diskon.route.ts +21 -0
- src/routes/index.ts +4 -0
- src/routes/ops.route.ts +1 -0
- src/services/bon.service.ts +35 -0
- src/services/chatbot.service.ts +406 -0
- src/services/ops.service.ts +207 -0
- src/utils/ai-client.ts +203 -0
- src/utils/chatbot-context.ts +279 -0
- src/utils/chatbot-tools.ts +1456 -0
- src/utils/supabase.ts +32 -0
.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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|