feat: idCardHash匹配、Roster分页过滤、社保基数调整修复、Modal size支持、Termination版本对比、UI优化

This commit is contained in:
freedakgmail
2026-07-23 23:26:10 +08:00
parent 32a57873cf
commit 559a567b9b
38 changed files with 4068 additions and 205 deletions
+209 -14
View File
@@ -9,6 +9,7 @@
"version": "1.0.0",
"dependencies": {
"@prisma/client": "^5.18.0",
"@types/multer": "^2.2.0",
"bcryptjs": "^2.4.3",
"compression": "^1.7.4",
"cors": "^2.8.5",
@@ -17,9 +18,11 @@
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"multer": "^2.2.0",
"node-cron": "^3.0.3",
"openai": "^6.48.0",
"uuid": "^10.0.0",
"xlsx": "^0.18.5",
"zod": "^3.23.0"
},
"devDependencies": {
@@ -628,7 +631,6 @@
"version": "1.19.6",
"resolved": "https://registry.npmmirror.com/@types/body-parser/-/body-parser-1.19.6.tgz",
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/connect": "*",
@@ -650,7 +652,6 @@
"version": "3.4.38",
"resolved": "https://registry.npmmirror.com/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
@@ -670,7 +671,6 @@
"version": "4.17.25",
"resolved": "https://registry.npmmirror.com/@types/express/-/express-4.17.25.tgz",
"integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/body-parser": "*",
@@ -683,7 +683,6 @@
"version": "4.19.9",
"resolved": "https://registry.npmmirror.com/@types/express-serve-static-core/-/express-serve-static-core-4.19.9.tgz",
"integrity": "sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
@@ -696,7 +695,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmmirror.com/@types/http-errors/-/http-errors-2.0.5.tgz",
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/jsonwebtoken": {
@@ -714,7 +712,6 @@
"version": "1.3.5",
"resolved": "https://registry.npmmirror.com/@types/mime/-/mime-1.3.5.tgz",
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/morgan": {
@@ -734,11 +731,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/multer": {
"version": "2.2.0",
"resolved": "https://registry.npmmirror.com/@types/multer/-/multer-2.2.0.tgz",
"integrity": "sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==",
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/node": {
"version": "20.19.43",
"resolved": "https://registry.npmmirror.com/@types/node/-/node-20.19.43.tgz",
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
@@ -755,21 +760,18 @@
"version": "6.15.1",
"resolved": "https://registry.npmmirror.com/@types/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/range-parser": {
"version": "1.2.7",
"resolved": "https://registry.npmmirror.com/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/send": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/@types/send/-/send-1.2.1.tgz",
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
@@ -779,7 +781,6 @@
"version": "1.15.10",
"resolved": "https://registry.npmmirror.com/@types/serve-static/-/serve-static-1.15.10.tgz",
"integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/http-errors": "*",
@@ -791,7 +792,6 @@
"version": "0.17.6",
"resolved": "https://registry.npmmirror.com/@types/send/-/send-0.17.6.tgz",
"integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/mime": "^1",
@@ -867,6 +867,15 @@
"node": ">=0.4.0"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmmirror.com/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmmirror.com/anymatch/-/anymatch-3.1.3.tgz",
@@ -881,6 +890,12 @@
"node": ">= 8"
}
},
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/arg": {
"version": "4.1.3",
"resolved": "https://registry.npmmirror.com/arg/-/arg-4.1.3.tgz",
@@ -996,9 +1011,19 @@
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true,
"license": "MIT"
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmmirror.com/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz",
@@ -1037,6 +1062,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmmirror.com/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz",
@@ -1062,6 +1100,15 @@
"fsevents": "~2.3.2"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmmirror.com/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/compressible": {
"version": "2.0.18",
"resolved": "https://registry.npmmirror.com/compressible/-/compressible-2.0.18.tgz",
@@ -1099,6 +1146,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmmirror.com/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -1152,6 +1214,18 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/create-require": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/create-require/-/create-require-1.1.1.tgz",
@@ -1433,6 +1507,15 @@
"node": ">= 0.6"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmmirror.com/fresh/-/fresh-0.5.2.tgz",
@@ -1948,6 +2031,25 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/multer": {
"version": "2.2.0",
"resolved": "https://registry.npmmirror.com/multer/-/multer-2.2.0.tgz",
"integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"type-is": "^1.6.18"
},
"engines": {
"node": ">= 10.16.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/negotiator": {
"version": "0.6.4",
"resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.4.tgz",
@@ -2188,6 +2290,20 @@
"node": ">= 0.8"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz",
@@ -2419,6 +2535,18 @@
"source-map": "^0.6.0"
}
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmmirror.com/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz",
@@ -2428,6 +2556,23 @@
"node": ">= 0.8"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/strip-bom": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/strip-bom/-/strip-bom-3.0.0.tgz",
@@ -2617,6 +2762,12 @@
"node": ">= 0.6"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmmirror.com/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
@@ -2635,7 +2786,6 @@
"version": "6.21.0",
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/unpipe": {
@@ -2647,6 +2797,12 @@
"node": ">= 0.8"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/utils-merge/-/utils-merge-1.0.1.tgz",
@@ -2685,6 +2841,24 @@
"node": ">= 0.8"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmmirror.com/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz",
@@ -2692,6 +2866,27 @@
"dev": true,
"license": "ISC"
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmmirror.com/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmmirror.com/xtend/-/xtend-4.0.2.tgz",
+3
View File
@@ -13,6 +13,7 @@
},
"dependencies": {
"@prisma/client": "^5.18.0",
"@types/multer": "^2.2.0",
"bcryptjs": "^2.4.3",
"compression": "^1.7.4",
"cors": "^2.8.5",
@@ -21,9 +22,11 @@
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"multer": "^2.2.0",
"node-cron": "^3.0.3",
"openai": "^6.48.0",
"uuid": "^10.0.0",
"xlsx": "^0.18.5",
"zod": "^3.23.0"
},
"devDependencies": {
+3
View File
@@ -173,6 +173,7 @@ model Employee {
gender String?
phone String?
idCardNumber String? // AES-256 加密存储
idCardHash String? // SHA-256 哈希,用于按身份证号查询匹配
emergencyContact String?
emergencyPhone String?
address String?
@@ -209,6 +210,8 @@ model Employee {
socialInsRecords EmployeeSocialInsRecord[]
housingFundRecords EmployeeHousingFundRecord[]
departmentRecords EmployeeDepartmentRecord[]
@@unique([orgId, idCardHash])
}
model LaborContract {
+10
View File
@@ -39,6 +39,8 @@ import socialRoutes from './routes/social.routes'
import notificationRoutes from './routes/notification.routes'
import attachmentRoutes from './routes/attachment.routes'
import rosterRoutes from './routes/roster.routes'
import exportRoutes from './routes/export.routes'
import importRoutes from './routes/import.routes'
app.use('/api/v1/auth', authRoutes)
app.use('/api/v1/dashboard', dashboardRoutes)
app.use('/api/v1/employees', employeeRoutes)
@@ -52,7 +54,15 @@ app.use('/api/v1/social', socialRoutes)
app.use('/api/v1/notifications', notificationRoutes)
app.use('/api/v1/attachments', attachmentRoutes)
app.use('/api/v1/roster', rosterRoutes)
app.use('/api/v1/export', exportRoutes)
app.use('/api/v1/import', importRoutes)
app.use(errorHandler)
// RAG 知识库自动初始化(异步,不阻塞启动)
import { seedKnowledgeBase } from './services/rag.service'
seedKnowledgeBase().catch((err) => {
console.warn('[RAG] 知识库初始化失败,AI 问答将不使用 RAG 检索:', err?.message || err)
})
export default app
+4
View File
@@ -20,3 +20,7 @@ export function decrypt(encryptedText: string): string {
decrypted += decipher.final('utf8')
return decrypted
}
export function sha256(text: string): string {
return crypto.createHash('sha256').update(text, 'utf8').digest('hex')
}
+107 -1
View File
@@ -1,10 +1,51 @@
import { Router } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { chat, reviewContract, matchCase, predictRisks } from '../services/ai.service'
import { chat, chatStream, reviewContract, matchCase, predictRisks } from '../services/ai.service'
import { seedKnowledgeBase, addKnowledge, searchKnowledge } from '../services/rag.service'
import prisma from '../lib/prisma'
const router = Router()
const PLAN_LIMITS: Record<string, { chat: number; review: number; case: number }> = {
FREE: { chat: 10, review: 3, case: 3 },
PRO: { chat: 100, review: 20, case: 20 },
ENTERPRISE: { chat: 0, review: 0, case: 0 },
}
async function checkUsageLimit(orgId: string, type: 'chat' | 'review' | 'case'): Promise<void> {
const org = await prisma.organization.findUnique({ where: { id: orgId } })
if (!org) return
const limits = PLAN_LIMITS[org.plan] || PLAN_LIMITS.FREE
const limit = limits[type]
if (limit === 0) return
const month = new Date().toISOString().slice(0, 7)
const count = await prisma.auditLog.count({
where: {
orgId,
action: `AI_${type.toUpperCase()}`,
detail: JSON.stringify({ month }) as any,
},
})
if (count >= limit) {
throw { code: 'USAGE_LIMIT', message: `本月 AI${type === 'chat' ? '问答' : type === 'review' ? '合同审查' : '案例匹配'}次数已达上限(${limit}次),请升级套餐` }
}
}
async function recordUsage(orgId: string, userId: string, type: 'chat' | 'review' | 'case'): Promise<void> {
const month = new Date().toISOString().slice(0, 7)
await prisma.auditLog.create({
data: {
orgId,
userId,
action: `AI_${type.toUpperCase()}`,
entity: 'AI',
entityId: null,
detail: { month, type } as any,
ip: '',
},
})
}
async function buildOrgContext(orgId: string): Promise<string> {
const [employees, risks] = await Promise.all([
prisma.employee.findMany({
@@ -37,21 +78,48 @@ router.post('/chat', authMiddleware, async (req: AuthRequest, res, next) => {
if (!messages || !Array.isArray(messages)) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 messages 参数' } })
}
await checkUsageLimit(req.user!.orgId, 'chat')
const orgContext = await buildOrgContext(req.user!.orgId)
const reply = await chat(messages, orgContext)
await recordUsage(req.user!.orgId, req.user!.id, 'chat')
res.json({ success: true, data: { reply } })
} catch (err) {
next(err)
}
})
router.post('/chat-stream', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { messages } = req.body as { messages: { role: 'user' | 'assistant'; content: string }[] }
if (!messages || !Array.isArray(messages)) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 messages 参数' } })
}
await checkUsageLimit(req.user!.orgId, 'chat')
const orgContext = await buildOrgContext(req.user!.orgId)
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
for await (const delta of chatStream(messages, orgContext)) {
res.write(`data: ${JSON.stringify({ delta })}\n\n`)
}
res.write('data: [DONE]\n\n')
res.end()
await recordUsage(req.user!.orgId, req.user!.id, 'chat')
} catch (err) {
if (!res.headersSent) next(err)
else res.end()
}
})
router.post('/review', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { contractText } = req.body as { contractText: string }
if (!contractText) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少合同文本' } })
}
await checkUsageLimit(req.user!.orgId, 'review')
const result = await reviewContract(contractText)
await recordUsage(req.user!.orgId, req.user!.id, 'review')
res.json({ success: true, data: { result } })
} catch (err) {
next(err)
@@ -64,7 +132,9 @@ router.post('/match-case', authMiddleware, async (req: AuthRequest, res, next) =
if (!scenario) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少争议情形描述' } })
}
await checkUsageLimit(req.user!.orgId, 'case')
const result = await matchCase(scenario)
await recordUsage(req.user!.orgId, req.user!.id, 'case')
res.json({ success: true, data: { result } })
} catch (err) {
next(err)
@@ -81,4 +151,40 @@ router.get('/predict', authMiddleware, async (req: AuthRequest, res, next) => {
}
})
// RAG 知识库管理
router.post('/rag/seed', authMiddleware, async (req: AuthRequest, res, next) => {
try {
await seedKnowledgeBase()
res.json({ success: true, data: { message: '知识库初始化完成' } })
} catch (err) {
next(err)
}
})
router.post('/rag/add', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { title, content, source, category } = req.body
if (!title || !content) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 title 或 content' } })
}
const result = await addKnowledge(title, content, source || '自定义', category || '其他')
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
})
router.post('/rag/search', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { query, topK } = req.body
if (!query) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 query' } })
}
const results = await searchKnowledge(query, topK || 5)
res.json({ success: true, data: { results } })
} catch (err) {
next(err)
}
})
export default router
+44 -1
View File
@@ -1,10 +1,14 @@
import { Router } from 'express'
import { registerSchema, loginSchema, refreshSchema, resetPasswordSchema } from '../schemas/auth.schema'
import { registerSchema, loginSchema, refreshSchema, resetPasswordSchema, forgotPasswordSchema, verifyCodeSchema } from '../schemas/auth.schema'
import { register, login, refresh, resetPassword } from '../services/auth.service'
import { authLimiter, loginLimiter } from '../middleware/rateLimit'
import prisma from '../lib/prisma'
import bcrypt from 'bcryptjs'
const router = Router()
const codeStore = new Map<string, { code: string; expiresAt: number }>()
router.post('/register', authLimiter, async (req, res, next) => {
try {
const data = registerSchema.parse(req.body)
@@ -35,6 +39,45 @@ router.post('/refresh', async (req, res, next) => {
}
})
// 发送重置验证码
router.post('/forgot-password/send-code', authLimiter, async (req, res, next) => {
try {
const data = forgotPasswordSchema.parse(req.body)
const user = await prisma.user.findUnique({ where: { phone: data.phone } })
if (!user) {
return res.status(400).json({ success: false, error: { code: 'NOT_FOUND', message: '该手机号未注册' } })
}
const code = Math.random().toString().slice(2, 8)
codeStore.set(data.phone, { code, expiresAt: Date.now() + 5 * 60 * 1000 })
res.json({ success: true, data: { code, message: '验证码已生成(开发阶段直接返回,生产环境将发送短信)' } })
} catch (err) {
next(err)
}
})
// 验证码重置密码
router.post('/forgot-password/verify', authLimiter, async (req, res, next) => {
try {
const data = verifyCodeSchema.parse(req.body)
const stored = codeStore.get(data.phone)
if (!stored || stored.expiresAt < Date.now()) {
return res.status(400).json({ success: false, error: { code: 'CODE_EXPIRED', message: '验证码已过期,请重新获取' } })
}
if (stored.code !== data.code) {
return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: '验证码错误' } })
}
codeStore.delete(data.phone)
const passwordHash = await bcrypt.hash(data.newPassword, 10)
await prisma.user.updateMany({
where: { phone: data.phone },
data: { passwordHash },
})
res.json({ success: true, data: { message: '密码重置成功' } })
} catch (err) {
next(err)
}
})
router.post('/reset-password', authLimiter, async (req, res, next) => {
try {
const data = resetPasswordSchema.parse(req.body)
+84
View File
@@ -1,6 +1,7 @@
import { Router } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { auditLog } from '../middleware/auditLog'
import prisma from '../lib/prisma'
import {
createEmployeeSchema,
updateEmployeeSchema,
@@ -91,6 +92,89 @@ router.delete('/:id', authMiddleware, async (req: AuthRequest, res, next) => {
}
})
// 批量续签合规预检
router.post('/contracts/preview-renew', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { contractIds } = req.body as { contractIds: string[] }
if (!contractIds || !Array.isArray(contractIds) || contractIds.length === 0) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 contractIds' } })
}
const contracts = await prisma.laborContract.findMany({
where: { id: { in: contractIds }, orgId: req.user!.orgId },
include: { employee: true },
orderBy: { startDate: 'asc' },
})
if (contracts.length === 0) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '未找到符合条件的合同' } })
}
// 合规检查:按员工分组,检查历史固定期合同次数
const results = []
for (const contract of contracts) {
const employee = contract.employee
// 查找该员工所有历史固定期合同(按时间正序,用于判断续签次数)
const allFixedContracts = await prisma.laborContract.findMany({
where: {
employeeId: contract.employeeId,
orgId: req.user!.orgId,
contractType: 'FIXED',
},
orderBy: { startDate: 'asc' },
})
// 当前合同是第几次固定期(从1开始计数)
const currentIndex = allFixedContracts.findIndex((c) => c.id === contract.id)
const renewalCount = currentIndex + 1
// 判断是否应签无固定期限:
// 1. 已连续签订2次以上固定期限合同(第3次应签无固定期限)
// 2. 员工连续工作满10年
const shouldBeUnfixed = renewalCount >= 2
const yearsSinceHire = (Date.now() - new Date(employee.hireDate).getTime()) / (365.25 * 24 * 60 * 60 * 1000)
const shouldBeUnfixedByTenure = yearsSinceHire >= 10
let warning: string | null = null
let suggestion: string | null = null
if (shouldBeUnfixed || shouldBeUnfixedByTenure) {
warning = shouldBeUnfixed
? `该员工已有 ${renewalCount} 次固定期限合同续签记录(《劳动合同法》第14条),第三次续签应订立无固定期限劳动合同`
: `该员工在本公司连续工作 ${Math.floor(yearsSinceHire)} 年(《劳动合同法》第14条),应订立无固定期限劳动合同`
suggestion = '建议与员工协商订立无固定期限劳动合同,以规避法律风险'
} else {
suggestion = `可续签固定期限(当前为第 ${renewalCount} 次续签)`
}
results.push({
contractId: contract.id,
employeeId: contract.employeeId,
employeeName: employee.name,
department: employee.department,
currentContractType: contract.contractType,
renewalCount,
yearsSinceHire: Math.floor(yearsSinceHire * 10) / 10,
warning,
suggestion,
canRenewFixed: !warning,
})
}
res.json({
success: true,
data: {
total: results.length,
warnings: results.filter((r) => r.warning).length,
results,
},
})
} catch (err) {
next(err)
}
})
router.post('/contracts/batch-renew', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const data = batchRenewSchema.parse(req.body)
+52
View File
@@ -0,0 +1,52 @@
import { Router, Response } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import prisma from '../lib/prisma'
import { decrypt } from '../lib/crypto'
const router = Router()
router.get('/all', authMiddleware, async (req: AuthRequest, res: Response, next) => {
try {
const orgId = req.user!.orgId
const [employees, contracts, terminations, payrollBatches, payslips, socialRecords, housingRecords, riskItems] = await Promise.all([
prisma.employee.findMany({ where: { orgId } }),
prisma.laborContract.findMany({ where: { orgId } }),
prisma.terminationRecord.findMany({ where: { orgId } }),
prisma.payrollBatch.findMany({ where: { orgId } }),
prisma.payslip.findMany({ where: { orgId } }),
prisma.employeeSocialInsRecord.findMany({ where: { orgId } }),
prisma.employeeHousingFundRecord.findMany({ where: { orgId } }),
prisma.riskItem.findMany({ where: { orgId } }),
])
const safeEmployees = employees.map((e) => {
let salary = 0
try { salary = Number(decrypt(e.monthlySalary)) || 0 } catch { salary = Number(e.monthlySalary) || 0 }
let idCard = null
try { if (e.idCardNumber) idCard = decrypt(e.idCardNumber) } catch { idCard = e.idCardNumber }
return { ...e, monthlySalary: salary, idCardNumber: idCard }
})
const data = {
exportedAt: new Date().toISOString(),
orgId,
employees: safeEmployees,
contracts,
terminations,
payrollBatches,
payslips,
socialRecords,
housingRecords,
riskItems,
}
res.setHeader('Content-Type', 'application/json')
res.setHeader('Content-Disposition', `attachment; filename="export-${new Date().toISOString().slice(0, 10)}.json"`)
res.json(data)
} catch (err) {
next(err)
}
})
export default router
+418
View File
@@ -0,0 +1,418 @@
import { Router, Response } from 'express'
import multer from 'multer'
import * as XLSX from 'xlsx'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { encrypt, decrypt, sha256 } from '../lib/crypto'
import prisma from '../lib/prisma'
const router = Router()
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } })
function dateToMonth(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
}
function parseDate(v: any): Date | null {
if (!v) return null
if (v instanceof Date) return v
if (typeof v === 'number') {
const d = XLSX.SSF.parse_date_code(v)
if (d) return new Date(d.y, d.m - 1, d.d)
}
const s = String(v).trim()
if (/^\d{4}-\d{2}-\d{2}/.test(s)) return new Date(s)
if (/^\d{4}\/\d{2}\/\d{2}/.test(s)) return new Date(s.replace(/\//g, '-'))
return null
}
function val(v: any): string {
if (v == null) return ''
return String(v).trim()
}
function num(v: any): number {
const n = Number(v)
return isNaN(n) ? 0 : n
}
router.post('/excel', authMiddleware, upload.single('file'), async (req: AuthRequest, res: Response, next) => {
try {
if (!req.file) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请上传文件' } })
const orgId = req.user!.orgId
const userId = req.user!.id
const wb = XLSX.read(req.file.buffer, { type: 'buffer', cellDates: true })
const result: any = { employees: 0, contracts: 0, overtime: 0, disciplinary: 0, attendance: 0, errors: [] as string[] }
const empSheet = wb.Sheets['员工信息']
if (empSheet) {
const rows = XLSX.utils.sheet_to_json(empSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const name = val(r['姓名'])
if (!name) { result.errors.push(`员工第${i + 2}行:姓名为空,跳过`); continue }
const dept = val(r['部门']) || '未分配'
const hireDate = parseDate(r['入职日期'])
if (!hireDate) { result.errors.push(`员工第${i + 2}行:入职日期格式错误`); continue }
const salary = String(num(r['月工资']))
if (salary === '0') { result.errors.push(`员工第${i + 2}行:月工资为空`); continue }
const emp = await prisma.employee.create({
data: {
orgId, name, department: dept, hireDate,
monthlySalary: encrypt(salary),
gender: val(r['性别']) || null,
phone: val(r['手机号']) || null,
idCardNumber: val(r['身份证号']) ? encrypt(val(r['身份证号'])) : null,
idCardHash: val(r['身份证号']) ? sha256(val(r['身份证号'])) : null,
emergencyContact: val(r['紧急联系人']) || null,
emergencyPhone: val(r['紧急联系电话']) || null,
address: val(r['住址']) || null,
bankName: val(r['开户行']) || null,
bankAccount: val(r['银行账号']) ? encrypt(val(r['银行账号'])) : null,
socialInsBase: num(r['社保基数']) || num(salary),
housingFundBase: num(r['公积金基数']) || num(salary),
specialDeduction: num(r['专项附加扣除']) || 0,
isPregnant: val(r['孕期']) === '是',
isInMedicalPeriod: val(r['医疗期']) === '是',
isWorkInjured: val(r['工伤']) === '是',
socialInsStartMonth: dateToMonth(hireDate),
housingFundStartMonth: dateToMonth(hireDate),
createdBy: userId,
},
})
await prisma.employeeSocialInsRecord.create({ data: { orgId, employeeId: emp.id, startMonth: dateToMonth(hireDate), endMonth: null, base: num(r['社保基数']) || num(salary), changeType: 'ONBOARDING', createdBy: userId } })
await prisma.employeeHousingFundRecord.create({ data: { orgId, employeeId: emp.id, startMonth: dateToMonth(hireDate), endMonth: null, base: num(r['公积金基数']) || num(salary), changeType: 'ONBOARDING', createdBy: userId } })
await prisma.salaryChangeRecord.create({ data: { orgId, employeeId: emp.id, oldSalary: 0, newSalary: num(salary), effectiveDate: hireDate, effectiveMonth: dateToMonth(hireDate), endMonth: null, changeType: 'ONBOARDING', createdBy: userId } })
await prisma.employeeDepartmentRecord.create({ data: { orgId, employeeId: emp.id, oldDepartment: '', newDepartment: dept, effectiveMonth: dateToMonth(hireDate), endMonth: null, changeType: 'ONBOARDING', createdBy: userId } })
result.employees++
} catch (e: any) {
result.errors.push(`员工第${i + 2}行:${e?.message || '导入失败'}`)
}
}
}
const contractSheet = wb.Sheets['劳动合同']
if (contractSheet) {
const rows = XLSX.utils.sheet_to_json(contractSheet)
const employees = await prisma.employee.findMany({ where: { orgId }, select: { id: true, name: true, idCardHash: true } })
const empByHash = new Map(employees.filter(e => e.idCardHash).map(e => [e.idCardHash, e.id]))
const empByName = new Map(employees.map(e => [e.name, e.id]))
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const idCard = val(r['身份证号'])
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(r['姓名']))
if (!empId) { result.errors.push(`合同第${i + 2}行:找不到员工「${val(r['姓名'])}`); continue }
const startDate = parseDate(r['合同开始日期'])
if (!startDate) { result.errors.push(`合同第${i + 2}行:开始日期格式错误`); continue }
const typeMap: any = { '固定期限': 'FIXED', '无固定期限': 'UNFIXED', '未签': 'UNSIGNED' }
const contractType = typeMap[val(r['合同类型'])] || 'FIXED'
if (contractType !== 'UNSIGNED') {
await prisma.laborContract.create({
data: {
orgId, employeeId: empId,
signDate: parseDate(r['签订日期']) || null,
startDate,
endDate: parseDate(r['合同结束日期']) || null,
contractType,
signMethod: val(r['签订方式']) === '电子' ? 'ELECTRONIC' : 'PAPER',
contractYears: num(r['合同年限']) || 3,
probationMonths: num(r['试用期月数']) || 0,
probationSalary: num(r['试用期工资']) || 0,
createdBy: userId,
},
})
result.contracts++
}
} catch (e: any) {
result.errors.push(`合同第${i + 2}行:${e?.message || '导入失败'}`)
}
}
}
const otSheet = wb.Sheets['加班记录']
if (otSheet) {
const rows = XLSX.utils.sheet_to_json(otSheet)
const employees = await prisma.employee.findMany({ where: { orgId }, select: { id: true, name: true, idCardHash: true } })
const empByHash = new Map(employees.filter(e => e.idCardHash).map(e => [e.idCardHash, e.id]))
const empByName = new Map(employees.map(e => [e.name, e.id]))
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
const idCard = val(r['身份证号'])
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(r['姓名']))
if (!empId) { result.errors.push(`加班第${i + 2}行:找不到员工「${val(r['姓名'])}`); continue }
const date = parseDate(r['日期'])
if (!date) continue
const month = dateToMonth(date)
const hours = num(r['加班时长'])
const otType = val(r['加班类型']) || '工作日加班'
await prisma.overtimeRecord.create({ data: { orgId, employeeId: empId, month, weekdayHours: otType.includes('工作日') ? hours : 0, weekendHours: otType.includes('休息日') ? hours : 0, holidayHours: otType.includes('法定') ? hours : 0, createdBy: userId } as any })
result.overtime++
}
}
const discSheet = wb.Sheets['违纪记录']
if (discSheet) {
const rows = XLSX.utils.sheet_to_json(discSheet)
const employees = await prisma.employee.findMany({ where: { orgId }, select: { id: true, name: true, idCardHash: true } })
const empByHash = new Map(employees.filter(e => e.idCardHash).map(e => [e.idCardHash, e.id]))
const empByName = new Map(employees.map(e => [e.name, e.id]))
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
const idCard = val(r['身份证号'])
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(r['姓名']))
if (!empId) { result.errors.push(`违纪第${i + 2}行:找不到员工「${val(r['姓名'])}`); continue }
const date = parseDate(r['日期'])
if (!date) continue
const typeMap: any = { '迟到': 'LATE', '旷工': 'ABSENT', '不服从': 'INSUBORDINATION', '违纪': 'MISCONDUCT', '违规': 'VIOLATE_POLICY', '其他': 'OTHER' }
const sevMap: any = { '警告': 'WARNING', '严重': 'SERIOUS', '重度': 'SEVERE' }
const actMap: any = { '口头警告': 'ORAL_WARNING', '书面警告': 'WRITTEN_WARNING', '扣款': 'DEDUCTION', '降级': 'DEMOTION', '辞退': 'TERMINATION' }
await prisma.disciplinaryRecord.create({ data: { orgId, employeeId: empId, violationDate: date, violationType: typeMap[val(r['违纪类型'])] || 'OTHER', description: val(r['描述']), severity: sevMap[val(r['严重程度'])] || 'WARNING', action: actMap[val(r['处罚'])] || 'ORAL_WARNING', createdBy: userId } })
result.disciplinary++
}
}
const attSheet = wb.Sheets['考勤记录']
if (attSheet) {
const rows = XLSX.utils.sheet_to_json(attSheet)
const employees = await prisma.employee.findMany({ where: { orgId }, select: { id: true, name: true, idCardHash: true } })
const empByHash = new Map(employees.filter(e => e.idCardHash).map(e => [e.idCardHash, e.id]))
const empByName = new Map(employees.map(e => [e.name, e.id]))
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
const idCard = val(r['身份证号'])
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(r['姓名']))
if (!empId) { result.errors.push(`考勤第${i + 2}行:找不到员工「${val(r['姓名'])}`); continue }
const date = parseDate(r['日期'])
if (!date) continue
const statusMap: any = { '正常': 'NORMAL', '迟到': 'LATE', '早退': 'EARLY_LEAVE', '缺勤': 'ABSENT', '请假': 'LEAVE', '出差': 'BUSINESS_TRIP' }
await prisma.attendanceRecord.create({ data: { orgId, employeeId: empId, date, status: statusMap[val(r['考勤状态'])] || 'NORMAL', checkInTime: val(r['上班时间']) || null, checkOutTime: val(r['下班时间']) || null, remark: val(r['备注']) || null, createdBy: userId } })
result.attendance++
}
}
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
})
router.get('/template', authMiddleware, async (req: AuthRequest, res: Response) => {
const wb = XLSX.utils.book_new()
const empData = [
{ '姓名': '张三', '部门': '技术部', '性别': '男', '手机号': '13800138000', '身份证号': '110101199001011234', '入职日期': '2023-03-01', '月工资': 10000, '社保基数': 10000, '公积金基数': 10000, '专项附加扣除': 1000, '紧急联系人': '李四', '紧急联系电话': '13900139000', '住址': '北京市朝阳区', '开户行': '工商银行', '银行账号': '6222021234567890', '孕期': '否', '医疗期': '否', '工伤': '否' },
]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(empData), '员工信息')
const contractData = [
{ '姓名': '张三', '身份证号': '110101199001011234', '合同类型': '固定期限', '签订日期': '2023-03-01', '合同开始日期': '2023-03-01', '合同结束日期': '2026-03-01', '合同年限': 3, '签订方式': '纸质', '试用期月数': 3, '试用期工资': 8000 },
]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(contractData), '劳动合同')
const otData = [
{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-01-15', '加班时长': 2, '加班类型': '工作日加班', '倍率': 1.5, '是否审批': '是' },
]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(otData), '加班记录')
const discData = [
{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-01-10', '违纪类型': '警告', '描述': '迟到', '处罚': '口头警告' },
]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(discData), '违纪记录')
const attData = [
{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-01-15', '考勤状态': '正常', '上班时间': '09:00', '下班时间': '18:00', '备注': '' },
]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(attData), '考勤记录')
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', 'attachment; filename="import-template.xlsx"')
res.send(buf)
})
// ========== 月度导入 ==========
router.post('/monthly', authMiddleware, upload.single('file'), async (req: AuthRequest, res: Response, next) => {
try {
if (!req.file) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请上传文件' } })
const orgId = req.user!.orgId
const userId = req.user!.id
const month = val(req.body.month) || dateToMonth(new Date())
if (!/^\d{4}-\d{2}$/.test(month)) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '月份格式应为 YYYY-MM' } })
}
const wb = XLSX.read(req.file.buffer, { type: 'buffer', cellDates: true })
const result: any = { month, attendance: 0, overtime: 0, salaryChanges: 0, socialInsChanges: 0, housingFundChanges: 0, errors: [] as string[] }
const employees = await prisma.employee.findMany({ where: { orgId }, select: { id: true, name: true, monthlySalary: true, department: true, idCardHash: true } })
const empByHash = new Map(employees.filter(e => e.idCardHash).map(e => [e.idCardHash, e]))
const empByName = new Map(employees.map(e => [e.name, e]))
function findEmp(r: any) {
const idCard = val(r['身份证号'])
if (idCard) {
const emp = empByHash.get(sha256(idCard))
if (emp) return emp
}
return empByName.get(val(r['姓名']))
}
// 考勤记录
const attSheet = wb.Sheets['考勤记录']
if (attSheet) {
const rows = XLSX.utils.sheet_to_json(attSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const emp = findEmp(r)
if (!emp) { result.errors.push(`考勤第${i + 2}行:找不到员工「${val(r['姓名'])}`); continue }
const date = parseDate(r['日期'])
if (!date) { result.errors.push(`考勤第${i + 2}行:日期格式错误`); continue }
const statusMap: any = { '正常': 'NORMAL', '迟到': 'LATE', '早退': 'EARLY_LEAVE', '缺勤': 'ABSENT', '请假': 'LEAVE', '出差': 'BUSINESS_TRIP' }
await prisma.attendanceRecord.upsert({
where: { employeeId_date: { employeeId: emp.id, date } },
create: { orgId, employeeId: emp.id, date, status: statusMap[val(r['考勤状态'])] || 'NORMAL', checkInTime: val(r['上班时间']) || null, checkOutTime: val(r['下班时间']) || null, remark: val(r['备注']) || null, createdBy: userId },
update: { status: statusMap[val(r['考勤状态'])] || 'NORMAL', checkInTime: val(r['上班时间']) || null, checkOutTime: val(r['下班时间']) || null, remark: val(r['备注']) || null },
})
result.attendance++
} catch (e: any) { result.errors.push(`考勤第${i + 2}行:${e?.message || '导入失败'}`) }
}
}
// 加班记录
const otSheet = wb.Sheets['加班记录']
if (otSheet) {
const rows = XLSX.utils.sheet_to_json(otSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const emp = findEmp(r)
if (!emp) { result.errors.push(`加班第${i + 2}行:找不到员工「${val(r['姓名'])}`); continue }
const date = parseDate(r['日期'])
if (!date) { result.errors.push(`加班第${i + 2}行:日期格式错误`); continue }
const otMonth = dateToMonth(date)
const hours = num(r['加班时长'])
const otType = val(r['加班类型']) || '工作日加班'
await prisma.overtimeRecord.upsert({
where: { employeeId_month: { employeeId: emp.id, month: otMonth } },
create: { orgId, employeeId: emp.id, month: otMonth, weekdayHours: otType.includes('工作日') ? hours : 0, weekendHours: otType.includes('休息日') ? hours : 0, holidayHours: otType.includes('法定') ? hours : 0 } as any,
update: {
weekdayHours: { increment: otType.includes('工作日') ? hours : 0 },
weekendHours: { increment: otType.includes('休息日') ? hours : 0 },
holidayHours: { increment: otType.includes('法定') ? hours : 0 },
},
})
result.overtime++
} catch (e: any) { result.errors.push(`加班第${i + 2}行:${e?.message || '导入失败'}`) }
}
}
// 薪资调整
const salarySheet = wb.Sheets['薪资调整']
if (salarySheet) {
const rows = XLSX.utils.sheet_to_json(salarySheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const emp = findEmp(r)
if (!emp) { result.errors.push(`薪资第${i + 2}行:找不到员工「${val(r['姓名'])}`); continue }
const newSalary = num(r['调整后月薪'])
if (newSalary <= 0) { result.errors.push(`薪资第${i + 2}行:调整后月薪无效`); continue }
const effDate = parseDate(r['生效日期']) || new Date(month + '-01')
const effMonth = dateToMonth(effDate)
let oldSalary = 0
try { oldSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { oldSalary = 0 }
// 关闭之前有效记录
await prisma.salaryChangeRecord.updateMany({ where: { employeeId: emp.id, endMonth: null }, data: { endMonth: effMonth } })
await prisma.salaryChangeRecord.create({ data: { orgId, employeeId: emp.id, oldSalary, newSalary, effectiveDate: effDate, effectiveMonth: effMonth, endMonth: null, changeType: 'SALARY_CHANGE', reason: val(r['调薪原因']) || '月度导入', createdBy: userId } })
await prisma.employee.update({ where: { id: emp.id }, data: { monthlySalary: encrypt(String(newSalary)) } })
result.salaryChanges++
} catch (e: any) { result.errors.push(`薪资第${i + 2}行:${e?.message || '导入失败'}`) }
}
}
// 社保增减员
const socialSheet = wb.Sheets['社保变动']
if (socialSheet) {
const rows = XLSX.utils.sheet_to_json(socialSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const emp = findEmp(r)
if (!emp) { result.errors.push(`社保第${i + 2}行:找不到员工「${val(r['姓名'])}`); continue }
const changeType = val(r['变动类型'])
const base = num(r['缴费基数'])
if (changeType === '增员' || changeType === '调基') {
// 关闭之前有效记录
await prisma.employeeSocialInsRecord.updateMany({ where: { employeeId: emp.id, endMonth: null }, data: { endMonth: month } })
await prisma.employeeSocialInsRecord.create({ data: { orgId, employeeId: emp.id, startMonth: month, endMonth: null, base: base || 0, changeType: changeType === '增员' ? 'ONBOARDING' : 'ADJUST', createdBy: userId } })
await prisma.employee.update({ where: { id: emp.id }, data: { socialInsBase: base || 0, socialInsStartMonth: month, socialInsEndMonth: null } })
} else if (changeType === '减员') {
await prisma.employeeSocialInsRecord.updateMany({ where: { employeeId: emp.id, endMonth: null }, data: { endMonth: month, changeType: 'TERMINATION' } })
await prisma.employee.update({ where: { id: emp.id }, data: { socialInsEndMonth: month } })
}
result.socialInsChanges++
} catch (e: any) { result.errors.push(`社保第${i + 2}行:${e?.message || '导入失败'}`) }
}
}
// 公积金增减员
const hfSheet = wb.Sheets['公积金变动']
if (hfSheet) {
const rows = XLSX.utils.sheet_to_json(hfSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const emp = findEmp(r)
if (!emp) { result.errors.push(`公积金第${i + 2}行:找不到员工「${val(r['姓名'])}`); continue }
const changeType = val(r['变动类型'])
const base = num(r['缴费基数'])
if (changeType === '增员' || changeType === '调基') {
await prisma.employeeHousingFundRecord.updateMany({ where: { employeeId: emp.id, endMonth: null }, data: { endMonth: month } })
await prisma.employeeHousingFundRecord.create({ data: { orgId, employeeId: emp.id, startMonth: month, endMonth: null, base: base || 0, changeType: changeType === '增员' ? 'ONBOARDING' : 'ADJUST', createdBy: userId } })
await prisma.employee.update({ where: { id: emp.id }, data: { housingFundBase: base || 0, housingFundStartMonth: month, housingFundEndMonth: null } })
} else if (changeType === '减员') {
await prisma.employeeHousingFundRecord.updateMany({ where: { employeeId: emp.id, endMonth: null }, data: { endMonth: month, changeType: 'TERMINATION' } })
await prisma.employee.update({ where: { id: emp.id }, data: { housingFundEndMonth: month } })
}
result.housingFundChanges++
} catch (e: any) { result.errors.push(`公积金第${i + 2}行:${e?.message || '导入失败'}`) }
}
}
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
})
router.get('/monthly-template', authMiddleware, async (req: AuthRequest, res: Response) => {
const wb = XLSX.utils.book_new()
const attData = [{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-06-01', '考勤状态': '正常', '上班时间': '09:00', '下班时间': '18:00', '备注': '' }]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(attData), '考勤记录')
const otData = [{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-06-15', '加班时长': 2, '加班类型': '工作日加班' }]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(otData), '加班记录')
const salaryData = [{ '姓名': '张三', '身份证号': '110101199001011234', '调整后月薪': 12000, '生效日期': '2024-06-01', '调薪原因': '年度调薪' }]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(salaryData), '薪资调整')
const socialData = [{ '姓名': '张三', '身份证号': '110101199001011234', '变动类型': '调基', '缴费基数': 12000 }]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(socialData), '社保变动')
const hfData = [{ '姓名': '张三', '身份证号': '110101199001011234', '变动类型': '调基', '缴费基数': 12000 }]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(hfData), '公积金变动')
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', 'attachment; filename="monthly-import-template.xlsx"')
res.send(buf)
})
export default router
+29 -1
View File
@@ -144,11 +144,15 @@ router.get('/batches/archived/list', async (req: AuthRequest, res: Response, nex
// 获取批次列表
router.get('/batches', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { month } = req.query
const { month, monthFrom, monthTo, status, type } = req.query
const batches = await prisma.payrollBatch.findMany({
where: {
orgId: req.user!.orgId,
...(month ? { month: String(month) } : {}),
...(monthFrom ? { month: { gte: String(monthFrom) } } : {}),
...(monthTo ? { month: { lte: String(monthTo) } } : {}),
...(status ? { status: String(status) as any } : {}),
...(type ? { type: String(type) as any } : {}),
},
orderBy: [{ month: 'desc' }, { batchNo: 'asc' }],
})
@@ -179,6 +183,30 @@ router.get('/batches/:id', async (req: AuthRequest, res: Response, next: NextFun
}
})
// 重命名批次
router.put('/batches/:id/name', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { name } = req.body
if (!name || typeof name !== 'string' || name.trim().length === 0) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '批次名称不能为空' } })
}
const batch = await prisma.payrollBatch.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
if (batch.status === 'ARCHIVED') {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '已归档批次不可重命名' } })
}
const updated = await prisma.payrollBatch.update({
where: { id: req.params.id },
data: { name: name.trim() },
})
res.json({ success: true, data: { id: updated.id, name: updated.name } })
} catch (err) {
next(err)
}
})
// 创建批次
const createBatchSchema = z.object({
month: z.string().regex(/^\d{4}-\d{2}$/),
+108 -22
View File
@@ -18,30 +18,54 @@ function safeDecrypt(encrypted: string): number {
// ========== 花名册聚合 API ==========
// 花名册列表(含汇总信息)
// 花名册列表(含汇总信息,支持分页和过滤
router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const employees = await prisma.employee.findMany({
where: { orgId: req.user!.orgId },
orderBy: { createdAt: 'desc' },
include: {
contracts: { orderBy: { createdAt: 'desc' }, take: 1 },
terminations: { orderBy: { terminationDate: 'desc' }, take: 1 },
_count: {
select: {
disciplinaryRecords: true,
attendanceRecords: true,
trainingRecords: true,
performanceRecords: true,
payslips: true,
overtimeRecords: true,
},
},
},
})
const page = parseInt(req.query.page as string) || 1
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100)
const search = req.query.search as string
const status = req.query.status as string // ACTIVE | PRE_HIRE | RESIGNED
const contractStatus = req.query.contractStatus as string // active | expiring | expired | unsigned | etc.
const skip = (page - 1) * pageSize
const today = new Date()
today.setHours(0, 0, 0, 0)
const result = employees.map((e) => {
// 先查询满足 orgId 和搜索条件的员工
const whereBase: any = { orgId: req.user!.orgId }
if (search) {
whereBase.OR = [
{ name: { contains: search } },
{ department: { contains: search } },
]
}
const [total, employees] = await Promise.all([
prisma.employee.count({ where: whereBase }),
prisma.employee.findMany({
where: whereBase,
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
include: {
contracts: { orderBy: { createdAt: 'desc' }, take: 1 },
terminations: { orderBy: { terminationDate: 'desc' }, take: 1 },
_count: {
select: {
disciplinaryRecords: true,
attendanceRecords: true,
trainingRecords: true,
performanceRecords: true,
payslips: true,
overtimeRecords: true,
},
},
},
}),
])
// 计算动态状态和合同状态
let result = employees.map((e) => {
const latestContract = e.contracts[0] || null
const contractInfo = latestContract
? getContractStatus({
@@ -60,11 +84,12 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
})
const isResigned = e.terminations.some((t) => t.terminationDate <= today)
const isPreHire = !isResigned && e.hireDate > today
const dynamicStatus = isResigned ? 'RESIGNED' : (isPreHire ? 'PRE_HIRE' : 'ACTIVE')
return {
id: e.id,
name: e.name,
department: e.department,
status: isResigned ? 'RESIGNED' : (isPreHire ? 'PRE_HIRE' : 'ACTIVE'),
status: dynamicStatus,
hasTermination: e.terminations.length > 0,
latestTerminationDate: e.terminations[0]?.terminationDate || null,
latestTerminationType: e.terminations[0]?.type || null,
@@ -80,7 +105,25 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
counts: e._count,
}
})
res.json({ success: true, data: result })
// 前端过滤:状态和合同状态(因为合同状态需要后处理,不适合放 Prisma where
if (status) {
result = result.filter((e) => e.status === status)
}
if (contractStatus) {
result = result.filter((e) => e.contractStatus === contractStatus)
}
res.json({
success: true,
data: result,
pagination: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
})
} catch (err) {
next(err)
}
@@ -701,4 +744,47 @@ router.get('/:id/department-records', authMiddleware, async (req: AuthRequest, r
} catch (err) { next(err) }
})
// 30天内合同到期列表
router.get('/contracts/expiring', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const days = parseInt(req.query.days as string) || 30
const today = new Date()
today.setHours(0, 0, 0, 0)
const future = new Date(today)
future.setDate(future.getDate() + days)
const employees = await prisma.employee.findMany({
where: { orgId: req.user!.orgId, status: 'ACTIVE' },
include: {
contracts: {
where: {
endDate: { gte: today, lte: future },
contractType: 'FIXED',
},
orderBy: { endDate: 'asc' },
take: 1,
},
},
})
const result = employees
.filter(e => e.contracts.length > 0)
.map(e => {
const contract = e.contracts[0]
const endDate = new Date(contract.endDate!)
const daysLeft = Math.ceil((endDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
return {
employeeId: e.id,
employeeName: e.name,
department: e.department,
contractEndDate: contract.endDate,
daysLeft,
}
})
.sort((a, b) => a.daysLeft - b.daysLeft)
res.json({ success: true, data: result })
} catch (err) { next(err) }
})
export default router
+110 -4
View File
@@ -192,10 +192,10 @@ router.get('/config/:id/adjust-preview', async (req: AuthRequest, res: Response,
employeeId: emp.id,
name: emp.name,
department: emp.department,
oldSocialBase,
oldBase: oldSocialBase,
avgSalary,
monthlyWage,
suggestedSocialBase,
suggestedBase: suggestedSocialBase,
}
})
@@ -209,7 +209,7 @@ router.get('/config/:id/adjust-preview', async (req: AuthRequest, res: Response,
const adjustApplySchema = z.object({
items: z.array(z.object({
employeeId: z.string(),
newSocialBase: z.number(),
newBase: z.number(),
})),
})
@@ -234,7 +234,7 @@ router.post('/config/:id/adjust-apply', async (req: AuthRequest, res: Response,
let adjusted = 0
for (const item of items) {
const socialBase = Math.min(Math.max(item.newSocialBase, config.baseMin), config.baseMax)
const socialBase = Math.min(Math.max(item.newBase, config.baseMin), config.baseMax)
// 关闭旧社保记录
await prisma.employeeSocialInsRecord.updateMany({
@@ -274,6 +274,59 @@ router.post('/config/:id/adjust-apply', async (req: AuthRequest, res: Response,
}
})
// 重置社保基数调整(撤销本次调整,重新来过)
router.post('/config/:id/reset-adjustment', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params
const orgId = req.user!.orgId
const config = await prisma.socialInsuranceConfig.findFirst({
where: { id, orgId },
})
if (!config) return res.status(404).json({ success: false, message: '配置版本不存在' })
if (!config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本尚未执行过基数调整,无需重置' })
// 恢复 adjustmentDone 标志
await prisma.socialInsuranceConfig.update({
where: { id },
data: { adjustmentDone: false },
})
// 删除该版本创建的所有社保记录变更
await prisma.employeeSocialInsRecord.deleteMany({
where: {
orgId,
changeType: 'ADJUST',
startMonth: config.effectiveFrom,
},
})
// 恢复员工社保基数为调整前(找到 adjustment 前的最后一条记录)
const employees = await prisma.employee.findMany({
where: { orgId, status: 'ACTIVE' },
select: { id: true },
})
for (const emp of employees) {
const prevRecord = await prisma.employeeSocialInsRecord.findFirst({
where: { orgId, employeeId: emp.id, startMonth: { lt: config.effectiveFrom } },
orderBy: { startMonth: 'desc' },
})
await prisma.employee.update({
where: { id: emp.id },
data: {
socialInsBase: prevRecord?.base ?? null,
socialInsStartMonth: prevRecord?.startMonth ?? null,
},
})
}
res.json({ success: true, message: '社保基数调整已重置,可以重新调整' })
} catch (err) {
next(err)
}
})
// 社保计算(使用当前版本或指定月份版本)
const calcSchema = z.object({
base: z.number().positive(),
@@ -605,6 +658,59 @@ router.post('/housing-config/:id/adjust-apply', async (req: AuthRequest, res: Re
}
})
// 重置公积金基数调整(撤销本次调整,重新来过)
router.post('/housing-config/:id/reset-adjustment', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params
const orgId = req.user!.orgId
const config = await prisma.housingFundConfig.findFirst({
where: { id, orgId },
})
if (!config) return res.status(404).json({ success: false, message: '公积金配置版本不存在' })
if (!config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本尚未执行过基数调整,无需重置' })
// 恢复 adjustmentDone 标志
await prisma.housingFundConfig.update({
where: { id },
data: { adjustmentDone: false },
})
// 删除该版本创建的所有公积金记录变更
await prisma.employeeHousingFundRecord.deleteMany({
where: {
orgId,
changeType: 'ADJUST',
startMonth: config.effectiveFrom,
},
})
// 恢复员工公积金基数为调整前
const employees = await prisma.employee.findMany({
where: { orgId, status: 'ACTIVE' },
select: { id: true },
})
for (const emp of employees) {
const prevRecord = await prisma.employeeHousingFundRecord.findFirst({
where: { orgId, employeeId: emp.id, startMonth: { lt: config.effectiveFrom } },
orderBy: { startMonth: 'desc' },
})
await prisma.employee.update({
where: { id: emp.id },
data: {
housingFundBase: prevRecord?.base ?? null,
housingFundStartMonth: prevRecord?.startMonth ?? null,
},
})
}
res.json({ success: true, message: '公积金基数调整已重置,可以重新调整' })
} catch (err) {
next(err)
}
})
// ========== 月度增减员 ==========
// 社保月度增减员
+36 -1
View File
@@ -2,7 +2,7 @@ import { Router } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { auditLog } from '../middleware/auditLog'
import { terminationChecklistSchema } from '../schemas/termination.schema'
import { createTermination, createResignation, revokeTermination, getTerminations, getChecklistForReason, assessRisk, calculateCompensation } from '../services/termination.service'
import { createTermination, createResignation, revokeTermination, getTerminations, getChecklistForReason, assessRisk, batchTerminatePreview, batchTerminate } from '../services/termination.service'
import prisma from '../lib/prisma'
import { decrypt } from '../lib/crypto'
@@ -103,4 +103,39 @@ router.delete('/:id/revoke', authMiddleware, async (req: AuthRequest, res, next)
}
})
// 批量解聘预检
router.post('/batch/preview', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { items } = req.body as {
items: Array<{ employeeId: string; reason: string; terminationDate: string }>
}
if (!items || !Array.isArray(items) || items.length === 0) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 items' } })
}
const results = await batchTerminatePreview(req.user!.orgId, items)
res.json({ success: true, data: { total: results.length, warnings: results.filter(r => r.warnings.length > 0).length, results } })
} catch (err) {
next(err)
}
})
// 批量解聘执行
router.post('/batch', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { items } = req.body as {
items: Array<{ employeeId: string; reason: string; terminationDate: string; compensation?: number }>
}
if (!items || !Array.isArray(items) || items.length === 0) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 items' } })
}
const result = await batchTerminate(req.user!.orgId, req.user!.id, items)
for (const id of result.success) {
await auditLog(req, 'TERMINATE', 'EMPLOYEE', id, { batch: true })
}
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
})
export default router
+6
View File
@@ -27,3 +27,9 @@ export const resetPasswordSchema = z.object({
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
newPassword: z.string().min(8, '密码至少8位').max(32, '密码最多32位'),
})
export const verifyCodeSchema = z.object({
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
code: z.string().length(6, '验证码为6位数字'),
newPassword: z.string().min(8, '密码至少8位').max(32, '密码最多32位'),
})
+47 -2
View File
@@ -1,4 +1,5 @@
import OpenAI from 'openai'
import { searchKnowledge } from './rag.service'
const apiKey = process.env.DASHSCOPE_API_KEY || ''
const baseURL = 'https://dashscope.aliyuncs.com/compatible-mode/v1'
@@ -21,9 +22,20 @@ const SYSTEM_PROMPT = `你是一个专业的劳动用工合规顾问,精通中
- 回答简洁有力,避免冗长`
export async function chat(messages: { role: 'user' | 'assistant'; content: string }[], orgContext?: string) {
const lastUserMsg = messages.filter(m => m.role === 'user').pop()
let ragContext = ''
if (lastUserMsg) {
try {
const knowledge = await searchKnowledge(lastUserMsg.content, 3)
if (knowledge.length > 0) {
ragContext = `\n\n相关法律条文(RAG检索结果):\n${knowledge.join('\n\n')}`
}
} catch { /* RAG not available, continue without */ }
}
const systemMessage = orgContext
? `${SYSTEM_PROMPT}\n\n当前企业数据概览:\n${orgContext}`
: SYSTEM_PROMPT
? `${SYSTEM_PROMPT}\n\n当前企业数据概览:\n${orgContext}${ragContext}`
: `${SYSTEM_PROMPT}${ragContext}`
const response = await client.chat.completions.create({
model: 'qwen-plus',
@@ -38,6 +50,39 @@ export async function chat(messages: { role: 'user' | 'assistant'; content: stri
return response.choices[0]?.message?.content || ''
}
export async function* chatStream(messages: { role: 'user' | 'assistant'; content: string }[], orgContext?: string) {
const lastUserMsg = messages.filter(m => m.role === 'user').pop()
let ragContext = ''
if (lastUserMsg) {
try {
const knowledge = await searchKnowledge(lastUserMsg.content, 3)
if (knowledge.length > 0) {
ragContext = `\n\n相关法律条文(RAG检索结果):\n${knowledge.join('\n\n')}`
}
} catch { /* RAG not available, continue without */ }
}
const systemMessage = orgContext
? `${SYSTEM_PROMPT}\n\n当前企业数据概览:\n${orgContext}${ragContext}`
: `${SYSTEM_PROMPT}${ragContext}`
const stream = await client.chat.completions.create({
model: 'qwen-plus',
messages: [
{ role: 'system', content: systemMessage },
...messages,
],
temperature: 0.7,
max_tokens: 2000,
stream: true,
})
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta?.content
if (delta) yield delta
}
}
export async function reviewContract(contractText: string) {
const prompt = `请审查以下劳动合同文本的合法性,逐条检查并标注风险等级(红/黄/绿),给出修改建议,最后给出合规评分(0-100分)。
+10 -1
View File
@@ -1,5 +1,5 @@
import prisma from '../lib/prisma'
import { encrypt, decrypt } from '../lib/crypto'
import { encrypt, decrypt, sha256 } from '../lib/crypto'
import { runRiskDetection } from './risk.service'
function daysBetween(a: Date, b: Date): number {
@@ -167,6 +167,14 @@ export async function getEmployeeDetail(orgId: string, id: string) {
}
export async function createEmployee(orgId: string, userId: string, data: any) {
const org = await prisma.organization.findUnique({ where: { id: orgId } })
if (org && org.maxEmployees > 0) {
const activeCount = await prisma.employee.count({ where: { orgId, status: 'ACTIVE' } })
if (activeCount >= org.maxEmployees) {
throw { code: 'PLAN_LIMIT', message: `当前套餐人数上限为 ${org.maxEmployees} 人,已达上限,请升级套餐` }
}
}
const hireDate = new Date(data.hireDate)
const hireMonth = dateToMonth(hireDate)
const salaryNum = Number(data.monthlySalary) || 0
@@ -185,6 +193,7 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
gender: data.gender,
phone: data.phone,
idCardNumber: data.idCardNumber ? encrypt(data.idCardNumber) : null,
idCardHash: data.idCardNumber ? sha256(data.idCardNumber) : null,
isPregnant: data.isPregnant || false,
isInMedicalPeriod: data.isInMedicalPeriod || false,
isWorkInjured: data.isWorkInjured || false,
+97
View File
@@ -0,0 +1,97 @@
import OpenAI from 'openai'
import prisma from '../lib/prisma'
const apiKey = process.env.DASHSCOPE_API_KEY || ''
const baseURL = 'https://dashscope.aliyuncs.com/compatible-mode/v1'
const client = new OpenAI({ apiKey, baseURL })
const EMBEDDING_MODEL = 'text-embedding-v2'
interface KnowledgeSeed {
title: string
content: string
source: string
category: string
}
const SEED_DATA: KnowledgeSeed[] = [
{ title: '劳动合同法 第十条 建立劳动关系应当订立书面合同', content: '建立劳动关系,应当订立书面劳动合同。已建立劳动关系,未同时订立书面劳动合同的,应当自用工之日起一个月内订立书面劳动合同。', source: '劳动合同法', category: '合同签订' },
{ title: '劳动合同法 第八十二条 未签书面合同双倍工资', content: '用人单位自用工之日起超过一个月不满一年未与劳动者订立书面劳动合同的,应当向劳动者每月支付二倍的工资。', source: '劳动合同法', category: '合同签订' },
{ title: '劳动合同法 第十四条 无固定期限劳动合同', content: '连续订立二次固定期限劳动合同续订的,应当订立无固定期限劳动合同。劳动者在该用人单位连续工作满十年的,应当订立无固定期限劳动合同。', source: '劳动合同法', category: '合同签订' },
{ title: '劳动合同法 第十九条 试用期期限', content: '三个月以上不满一年试用期不得超过一个月;一年以上不满三年不得超过二个月;三年以上不得超过六个月。同一用人单位与同一劳动者只能约定一次试用期。', source: '劳动合同法', category: '试用期' },
{ title: '劳动合同法 第二十条 试用期工资', content: '试用期工资不得低于本单位相同岗位最低档工资或劳动合同约定工资的百分之八十,并不得低于最低工资标准。', source: '劳动合同法', category: '试用期' },
{ title: '劳动合同法 第三十九条 过失性辞退', content: '严重违反规章制度、严重失职造成重大损害、被依法追究刑事责任等情形,用人单位可以解除劳动合同。', source: '劳动合同法', category: '解除终止' },
{ title: '劳动合同法 第四十条 无过失性辞退', content: '提前三十日书面通知或额外支付一个月工资后可解除:医疗期满不能从事原工作、不能胜任经培训仍不胜任、客观情况重大变化未能协商一致。', source: '劳动合同法', category: '解除终止' },
{ title: '劳动合同法 第四十一条 经济性裁员', content: '裁减二十人以上或占职工总数百分之十以上,需提前三十日向工会说明,方案报劳动行政部门。优先留用长期合同、无固定期限合同、家庭无其他就业人员。', source: '劳动合同法', category: '解除终止' },
{ title: '劳动合同法 第四十二条 不得解除的情形', content: '职业病、因工负伤丧失劳动能力、医疗期内、孕期产期哺乳期、连续工作满十五年距退休不足五年等情形,不得依第四十条第四十一条解除。', source: '劳动合同法', category: '解除终止' },
{ title: '劳动合同法 第四十七条 经济补偿计算', content: '每满一年支付一个月工资。六个月以上不满一年按一年计算;不满六个月支付半个月工资。月工资指解除前十二个月平均工资。高于社平工资三倍的按三倍计,年限最高十二年。', source: '劳动合同法', category: '经济补偿' },
{ title: '劳动合同法 第八十七条 违法解除赔偿金', content: '用人单位违反本法规定解除或终止劳动合同的,应当依照第四十七条经济补偿标准的二倍向劳动者支付赔偿金。', source: '劳动合同法', category: '经济补偿' },
{ title: '劳动法 第四十一条 加班时间上限', content: '一般每日不得超过一小时;特殊原因每日不得超过三小时,每月不得超过三十六小时。', source: '劳动法', category: '加班' },
{ title: '劳动法 第四十四条 加班工资标准', content: '延长工作时间不低于工资150%;休息日加班不能补休的不低于200%;法定休假日不低于300%。', source: '劳动法', category: '加班' },
{ title: '社会保险法 第五十八条 参保登记', content: '用人单位应当自用工之日起三十日内为其职工向社会保险经办机构申请办理社会保险登记。', source: '社会保险法', category: '社保' },
{ title: '劳动合同法 第八十二条 二倍工资起算', content: '用人单位自用工之日起满一年不与劳动者订立书面劳动合同的,视为用人单位与劳动者已订立无固定期限劳动合同。', source: '劳动合同法', category: '合同签订' },
]
let initialized = false
export async function ensureRAGTable() {
if (initialized) return
await prisma.$executeRaw`CREATE EXTENSION IF NOT EXISTS vector`
await prisma.$executeRaw`
CREATE TABLE IF NOT EXISTS rag_knowledge (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
source TEXT NOT NULL,
category TEXT NOT NULL,
embedding vector(1536),
created_at TIMESTAMPTZ DEFAULT now()
)
`
await prisma.$executeRaw`CREATE INDEX IF NOT EXISTS rag_knowledge_embedding_idx ON rag_knowledge USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100)`
initialized = true
}
async function getEmbedding(text: string): Promise<number[]> {
const res = await client.embeddings.create({ model: EMBEDDING_MODEL, input: text })
return res.data[0]?.embedding || []
}
export async function seedKnowledgeBase() {
await ensureRAGTable()
const count = await prisma.$queryRaw`SELECT count(*)::int as c FROM rag_knowledge` as any
if (count[0]?.c > 0) return
for (let i = 0; i < SEED_DATA.length; i++) {
const item = SEED_DATA[i]
const embedding = await getEmbedding(`${item.title} ${item.content}`)
await prisma.$executeRaw`
INSERT INTO rag_knowledge (id, title, content, source, category, embedding)
VALUES (${`rag-${String(i).padStart(3, '0')}`}, ${item.title}, ${item.content}, ${item.source}, ${item.category}, ${embedding}::vector)
`
}
}
export async function searchKnowledge(query: string, topK: number = 3): Promise<string[]> {
await ensureRAGTable()
const queryEmbedding = await getEmbedding(query)
const results = await prisma.$queryRaw`
SELECT title, content, source, 1 - (embedding <=> ${queryEmbedding}::vector) as similarity
FROM rag_knowledge
ORDER BY embedding <=> ${queryEmbedding}::vector
LIMIT ${topK}
` as any[]
return results
.filter((r) => r.similarity > 0.3)
.map((r) => `${r.title}\n${r.content}\n(来源:${r.source},相似度:${(r.similarity * 100).toFixed(0)}%`)
}
export async function addKnowledge(title: string, content: string, source: string, category: string) {
await ensureRAGTable()
const embedding = await getEmbedding(`${title} ${content}`)
const id = `rag-${Date.now()}`
await prisma.$executeRaw`
INSERT INTO rag_knowledge (id, title, content, source, category, embedding)
VALUES (${id}, ${title}, ${content}, ${source}, ${category}, ${embedding}::vector)
`
return { id }
}
+146 -1
View File
@@ -1,6 +1,6 @@
import prisma from '../lib/prisma'
import { decrypt } from '../lib/crypto'
import { RiskAssessment } from '@prisma/client'
import { RiskAssessment, TerminationReason } from '@prisma/client'
function daysBetween(a: Date, b: Date): number {
return Math.floor((a.getTime() - b.getTime()) / (1000 * 60 * 60 * 24))
@@ -376,3 +376,148 @@ export function calculateCompensation(hireDate: Date, leaveDate: Date, monthlyWa
return { years, remainingMonths, compMonths, totalPay: wage * compMonths, capped }
}
// 批量解聘:支持合规预检和执行
export interface BatchTerminatePreview {
employeeId: string
employeeName: string
department: string
reason: string
terminationDate: string
riskLevel: RiskAssessment | null
warnings: string[]
canTerminate: boolean
}
export async function batchTerminatePreview(
orgId: string,
items: Array<{ employeeId: string; reason: string; terminationDate: string }>
): Promise<BatchTerminatePreview[]> {
const results: BatchTerminatePreview[] = []
for (const item of items) {
const employee = await prisma.employee.findFirst({
where: { id: item.employeeId, orgId },
})
if (!employee) {
results.push({
employeeId: item.employeeId,
employeeName: '(未找到)',
department: '',
reason: item.reason,
terminationDate: item.terminationDate,
riskLevel: null,
warnings: ['员工不存在或无权操作'],
canTerminate: false,
})
continue
}
const { level, warnings } = assessRisk(employee, item.reason)
results.push({
employeeId: item.employeeId,
employeeName: employee.name,
department: employee.department,
reason: item.reason,
terminationDate: item.terminationDate,
riskLevel: level,
warnings,
canTerminate: warnings.length === 0,
})
}
return results
}
export interface BatchTerminateResult {
success: string[]
failed: Array<{ employeeId: string; reason: string }>
total: number
}
export async function batchTerminate(
orgId: string,
userId: string,
items: Array<{ employeeId: string; reason: string; terminationDate: string; compensation?: number }>
): Promise<BatchTerminateResult> {
const success: string[] = []
const failed: Array<{ employeeId: string; reason: string }> = []
for (const item of items) {
try {
const termDate = new Date(item.terminationDate)
const termMonth = dateToMonth(termDate)
// 校验:已有离职/解聘记录
const latestTerm = await prisma.terminationRecord.findFirst({
where: { employeeId: item.employeeId },
orderBy: { terminationDate: 'desc' },
})
const employee = await prisma.employee.findFirst({ where: { id: item.employeeId, orgId } })
if (!employee) {
failed.push({ employeeId: item.employeeId, reason: '员工不存在' })
continue
}
if (latestTerm && latestTerm.terminationDate >= employee.hireDate) {
failed.push({ employeeId: item.employeeId, reason: '该员工已有离职/解聘记录' })
continue
}
const { level } = assessRisk(employee, item.reason)
await prisma.terminationRecord.create({
data: {
orgId,
employeeId: item.employeeId,
type: 'TERMINATION',
reason: item.reason as TerminationReason,
terminationDate: termDate,
compensation: item.compensation || 0,
socialInsEndMonth: termMonth,
housingFundEndMonth: termMonth,
riskLevel: level,
checklist: {},
remark: '批量解聘',
createdBy: userId,
},
})
// 关闭社保和公积金
await prisma.employeeSocialInsRecord.updateMany({
where: { employeeId: item.employeeId, endMonth: null },
data: { endMonth: termMonth },
})
await prisma.employeeHousingFundRecord.updateMany({
where: { employeeId: item.employeeId, endMonth: null },
data: { endMonth: termMonth },
})
// 更新员工状态
const today = new Date()
today.setHours(0, 0, 0, 0)
const isResigned = termDate <= today
await prisma.employee.update({
where: { id: item.employeeId },
data: {
status: isResigned ? 'RESIGNED' : 'ACTIVE',
socialInsEndMonth: termMonth,
housingFundEndMonth: termMonth,
},
})
// 关闭风险项
await prisma.riskItem.updateMany({
where: { employeeId: item.employeeId, status: 'PENDING' },
data: { status: 'RESOLVED', resolvedAt: new Date() },
})
success.push(item.employeeId)
} catch (err: any) {
failed.push({ employeeId: item.employeeId, reason: err.message || '未知错误' })
}
}
return { success, failed, total: items.length }
}
+3 -2
View File
@@ -17,5 +17,6 @@
}
},
"include": ["src/**/*", "prisma/**/*"],
"exclude": ["node_modules", "dist"]
}
"exclude": ["node_modules", "dist"],
"ignoreDeprecations": "6.0"
}