优化: 大文件拆分+代码分割+按需加载+console清理+any类型替换

- Money.tsx (2260行→54行): 拆分为 money/ 子目录4个组件, React.lazy二级分割
- AIAssistant.tsx (2038行→63行): 拆分为 ai-assistant/ 子目录6个组件, React.lazy二级分割
- xlsx改为动态导入, OvertimeTab从345KB降至12.7KB
- api-services.ts: 请求参数 any→Record<string,unknown>
- 移除前端3处console.log残留
- 后端console替换为pino logger
- 前后端未使用import/变量清理
- Zod schema验证: termination/platform/special-status/work-process
- 新增 leave.routes.ts, acceptance-test.routes.ts
- UI组件: PageGuide, QueryError, Stepper
This commit is contained in:
freedakgmail
2026-08-04 07:53:37 +08:00
parent 1da385cd5d
commit 2968484d2d
109 changed files with 8950 additions and 5926 deletions
+243
View File
@@ -24,6 +24,8 @@
"multer": "^2.2.0",
"node-cron": "^3.0.3",
"openai": "^6.48.0",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"redis": "^6.1.0",
"uuid": "^10.0.0",
"xlsx": "^0.18.5",
@@ -569,6 +571,12 @@
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmmirror.com/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT"
},
"node_modules/@prisma/client": {
"version": "5.22.0",
"resolved": "https://registry.npmmirror.com/@prisma/client/-/client-5.22.0.tgz",
@@ -1128,6 +1136,15 @@
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
"license": "MIT"
},
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -1456,6 +1473,12 @@
"node": ">=0.8"
}
},
"node_modules/colorette": {
"version": "2.0.20",
"resolved": "https://registry.npmmirror.com/colorette/-/colorette-2.0.20.tgz",
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
"license": "MIT"
},
"node_modules/compress-commons": {
"version": "4.1.2",
"resolved": "https://registry.npmmirror.com/compress-commons/-/compress-commons-4.1.2.tgz",
@@ -1613,6 +1636,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/dateformat": {
"version": "4.6.3",
"resolved": "https://registry.npmmirror.com/dateformat/-/dateformat-4.6.3.tgz",
"integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/dayjs": {
"version": "1.11.21",
"resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.21.tgz",
@@ -1945,6 +1977,12 @@
"express": ">= 4.11"
}
},
"node_modules/fast-copy": {
"version": "4.0.4",
"resolved": "https://registry.npmmirror.com/fast-copy/-/fast-copy-4.0.4.tgz",
"integrity": "sha512-eVAiWVNPSEGIzDl5yPuLrx8fNMogScXvD9xp1Kzd41FjRIz2I3sSIcxsFeM5EzFfHAfobdvs8ZySffUopljvIA==",
"license": "MIT"
},
"node_modules/fast-csv": {
"version": "4.3.6",
"resolved": "https://registry.npmmirror.com/fast-csv/-/fast-csv-4.3.6.tgz",
@@ -1958,6 +1996,12 @@
"node": ">=10.0.0"
}
},
"node_modules/fast-safe-stringify": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
"license": "MIT"
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz",
@@ -2201,6 +2245,12 @@
"node": ">=16.0.0"
}
},
"node_modules/help-me": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/help-me/-/help-me-5.0.0.tgz",
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
"license": "MIT"
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz",
@@ -2353,6 +2403,15 @@
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/joycon": {
"version": "3.1.1",
"resolved": "https://registry.npmmirror.com/joycon/-/joycon-3.1.1.tgz",
"integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmmirror.com/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@@ -2869,6 +2928,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz",
@@ -2985,6 +3053,79 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pino": {
"version": "10.3.1",
"resolved": "https://registry.npmmirror.com/pino/-/pino-10.3.1.tgz",
"integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
"license": "MIT",
"dependencies": {
"@pinojs/redact": "^0.4.0",
"atomic-sleep": "^1.0.0",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^3.0.0",
"pino-std-serializers": "^7.0.0",
"process-warning": "^5.0.0",
"quick-format-unescaped": "^4.0.3",
"real-require": "^0.2.0",
"safe-stable-stringify": "^2.3.1",
"sonic-boom": "^4.0.1",
"thread-stream": "^4.0.0"
},
"bin": {
"pino": "bin.js"
}
},
"node_modules/pino-abstract-transport": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
"license": "MIT",
"dependencies": {
"split2": "^4.0.0"
}
},
"node_modules/pino-pretty": {
"version": "13.1.3",
"resolved": "https://registry.npmmirror.com/pino-pretty/-/pino-pretty-13.1.3.tgz",
"integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==",
"license": "MIT",
"dependencies": {
"colorette": "^2.0.7",
"dateformat": "^4.6.3",
"fast-copy": "^4.0.0",
"fast-safe-stringify": "^2.1.1",
"help-me": "^5.0.0",
"joycon": "^3.1.1",
"minimist": "^1.2.6",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^3.0.0",
"pump": "^3.0.0",
"secure-json-parse": "^4.0.0",
"sonic-boom": "^4.0.1",
"strip-json-comments": "^5.0.2"
},
"bin": {
"pino-pretty": "bin.js"
}
},
"node_modules/pino-pretty/node_modules/strip-json-comments": {
"version": "5.0.3",
"resolved": "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-5.0.3.tgz",
"integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==",
"license": "MIT",
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pino-std-serializers": {
"version": "7.1.0",
"resolved": "https://registry.npmmirror.com/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"license": "MIT"
},
"node_modules/prisma": {
"version": "5.22.0",
"resolved": "https://registry.npmmirror.com/prisma/-/prisma-5.22.0.tgz",
@@ -3011,6 +3152,22 @@
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/process-warning": {
"version": "5.1.0",
"resolved": "https://registry.npmmirror.com/process-warning/-/process-warning-5.1.0.tgz",
"integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -3024,6 +3181,16 @@
"node": ">= 0.10"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmmirror.com/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.3.tgz",
@@ -3040,6 +3207,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmmirror.com/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/range-parser/-/range-parser-1.2.1.tgz",
@@ -3121,6 +3294,15 @@
"node": ">=8.10.0"
}
},
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmmirror.com/real-require/-/real-require-0.2.0.tgz",
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
"license": "MIT",
"engines": {
"node": ">= 12.13.0"
}
},
"node_modules/redis": {
"version": "6.1.0",
"resolved": "https://registry.npmmirror.com/redis/-/redis-6.1.0.tgz",
@@ -3192,6 +3374,15 @@
],
"license": "MIT"
},
"node_modules/safe-stable-stringify": {
"version": "2.5.0",
"resolved": "https://registry.npmmirror.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -3210,6 +3401,22 @@
"node": ">=10"
}
},
"node_modules/secure-json-parse": {
"version": "4.1.0",
"resolved": "https://registry.npmmirror.com/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
"integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz",
@@ -3351,6 +3558,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/sonic-boom": {
"version": "4.2.1",
"resolved": "https://registry.npmmirror.com/sonic-boom/-/sonic-boom-4.2.1.tgz",
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
"license": "MIT",
"dependencies": {
"atomic-sleep": "^1.0.0"
}
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz",
@@ -3372,6 +3588,15 @@
"source-map": "^0.6.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmmirror.com/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/sprintf-js/-/sprintf-js-1.0.3.tgz",
@@ -3465,6 +3690,24 @@
"node": ">=6"
}
},
"node_modules/thread-stream": {
"version": "4.2.0",
"resolved": "https://registry.npmmirror.com/thread-stream/-/thread-stream-4.2.0.tgz",
"integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==",
"license": "MIT",
"dependencies": {
"real-require": "^1.0.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/thread-stream/node_modules/real-require": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/real-require/-/real-require-1.0.0.tgz",
"integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==",
"license": "MIT"
},
"node_modules/tmp": {
"version": "0.2.7",
"resolved": "https://registry.npmmirror.com/tmp/-/tmp-0.2.7.tgz",
+2
View File
@@ -29,6 +29,8 @@
"multer": "^2.2.0",
"node-cron": "^3.0.3",
"openai": "^6.48.0",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"redis": "^6.1.0",
"uuid": "^10.0.0",
"xlsx": "^0.18.5",
@@ -0,0 +1,27 @@
-- 验收测试表
CREATE TABLE IF NOT EXISTS "AcceptanceTest" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"verifierName" TEXT NOT NULL,
"results" JSONB NOT NULL,
"remarks" JSONB NOT NULL,
"conclusionName" TEXT,
"conclusionDate" TEXT,
"conclusionResult" TEXT,
"conclusionIssues" TEXT,
"screenshots" JSONB,
"signature1" TEXT,
"signature2" TEXT,
"status" TEXT NOT NULL DEFAULT 'DRAFT',
"submittedAt" TIMESTAMP(3),
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AcceptanceTest_pkey" PRIMARY KEY ("id")
);
-- 唯一约束:同一组织下验收人姓名唯一
CREATE UNIQUE INDEX IF NOT EXISTS "AcceptanceTest_orgId_verifierName_key" ON "AcceptanceTest"("orgId", "verifierName");
-- 索引
CREATE INDEX IF NOT EXISTS "AcceptanceTest_orgId_status_idx" ON "AcceptanceTest"("orgId", "status");
+53
View File
@@ -175,6 +175,7 @@ model Organization {
shifts Shift[]
shiftAssignments ShiftAssignment[]
leaveRecords LeaveRecord[]
leaveRequests LeaveRequest[]
calendarEvents CalendarEvent[]
consultations Consultation[]
workProcesses WorkProcess[]
@@ -263,6 +264,7 @@ model Employee {
specialDeductionRecords SpecialDeductionRecord[]
shiftAssignments ShiftAssignment[]
leaveRecords LeaveRecord[]
leaveRequests LeaveRequest[]
calendarEvents CalendarEvent[]
workProcesses WorkProcess[]
specialStatuses EmployeeSpecialStatus[]
@@ -1162,6 +1164,33 @@ model LeaveRecord {
@@index([orgId, startDate])
}
// ========== 休假审批流 ==========
model LeaveRequest {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
leaveType String // SICK=病假 / PERSONAL=事假 / ANNUAL=年假 / MATERNITY=产假 / OTHER=其他
startDate DateTime
endDate DateTime
days Float // 请假天数
reason String?
attachment String? // 附件URL(如病假条)
status String @default("PENDING") // PENDING=待审批 / APPROVED=已批准 / REJECTED=已驳回 / CANCELLED=已撤回
approverId String? // 审批人ID
approvedAt DateTime?
approveRemark String? // 审批意见
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([orgId, employeeId])
@@index([orgId, status])
@@index([orgId, startDate])
}
// ========== 自定义日历事件 ==========
model CalendarEvent {
@@ -1311,3 +1340,27 @@ model EmployeeSpecialStatus {
@@index([orgId, type])
@@index([employeeId])
}
// ========== 验收测试 ==========
model AcceptanceTest {
id String @id @default(uuid())
orgId String
verifierName String // 验收人姓名
results Json // { "1.1": "pass", "1.2": "fail", ... }
remarks Json // { "1.1": "备注内容", ... }
conclusionName String? // 结论验收人
conclusionDate String? // 结论日期
conclusionResult String? // pass | conditional | fail — 系统自动计算
conclusionIssues String? // 遗留问题(系统自动汇总)
screenshots Json? // { "0-0": "data:image/png;base64,...", ... }
signature1 String? // 验收人签名 dataURL
signature2 String? // 项目经理签名 dataURL
status String @default("DRAFT") // DRAFT | SUBMITTED
submittedAt DateTime?
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([orgId, verifierName])
@@index([orgId, status])
}
+6 -1
View File
@@ -5,6 +5,7 @@ import morgan from 'morgan'
import compression from 'compression'
import { errorHandler } from './middleware/errorHandler'
import { apiLimiter } from './middleware/rateLimit'
import logger from './lib/logger'
const app = express()
@@ -62,6 +63,8 @@ import workProcessRoutes from './routes/work-process.routes'
import enterpriseTemplateRoutes from './routes/enterprise-template.routes'
import specialStatusRoutes from './routes/special-status.routes'
import companyFileRoutes from './routes/company-file.routes'
import acceptanceTestRoutes from './routes/acceptance-test.routes'
import leaveRoutes from './routes/leave.routes'
app.use('/api/v1/auth', authRoutes)
app.use('/api/v1/dashboard', dashboardRoutes)
app.use('/api/v1/employees', employeeRoutes)
@@ -88,13 +91,15 @@ app.use('/api/v1/work-processes', workProcessRoutes)
app.use('/api/v1/enterprise-templates', enterpriseTemplateRoutes)
app.use('/api/v1/special-statuses', specialStatusRoutes)
app.use('/api/v1/company-files', companyFileRoutes)
app.use('/api/v1/acceptance-tests', acceptanceTestRoutes)
app.use('/api/v1/leaves', leaveRoutes)
app.use(errorHandler)
// RAG 知识库自动初始化(异步,不阻塞启动)
import { seedKnowledgeBase } from './services/rag.service'
seedKnowledgeBase().catch((err) => {
console.warn('[RAG] 知识库初始化失败,AI 问答将不使用 RAG 检索:', err?.message || err)
logger.warn({ err }, 'RAG 知识库初始化失败,AI 问答将不使用 RAG 检索')
})
export default app
+2 -1
View File
@@ -1,10 +1,11 @@
import app from './app'
import { validateEnv } from './lib/config'
import logger from './lib/logger'
validateEnv()
const PORT = Number(process.env.PORT) || 3000
app.listen(PORT, '::', () => {
console.log(`Server running on http://[::]:${PORT}`)
logger.info(`Server running on http://[::]:${PORT}`)
})
+9 -7
View File
@@ -7,6 +7,8 @@
* 功能:存储、验证、过期清理、失败次数限制
*/
import logger from './logger'
interface CodeEntry {
code: string
expiresAt: number
@@ -28,11 +30,11 @@ async function getRedis() {
try {
const { createClient } = await import('redis')
redisClient = createClient({ url: REDIS_URL })
redisClient.on('error', (err: any) => console.error('[Redis] error:', err))
redisClient.on('error', (err: any) => logger.error({ err }, 'Redis error'))
await redisClient.connect()
console.log('[Redis] 验证码存储已连接')
logger.info('Redis 验证码存储已连接')
} catch (err) {
console.warn('[Redis] 连接失败,降级为内存存储:', err)
logger.warn({ err }, 'Redis 连接失败,降级为内存存储')
return null
}
}
@@ -59,7 +61,7 @@ export async function setCode(key: string, code: string, ttlMs: number = 5 * 60
await redis.set(`${KEY_PREFIX}${key}`, JSON.stringify(entry), { PX: ttlMs })
return
} catch (err) {
console.warn('[Redis] set 失败,降级内存:', err)
logger.warn({ err }, 'Redis set 失败,降级内存')
}
}
memoryStore.set(key, entry)
@@ -81,7 +83,7 @@ export async function getCode(key: string): Promise<CodeEntry | null> {
}
return entry
} catch (err) {
console.warn('[Redis] get 失败,降级内存:', err)
logger.warn({ err }, 'Redis get 失败,降级内存')
}
}
@@ -108,7 +110,7 @@ export async function updateCode(key: string, updates: Partial<CodeEntry>): Prom
}
return
} catch (err) {
console.warn('[Redis] update 失败,降级内存:', err)
logger.warn({ err }, 'Redis update 失败,降级内存')
}
}
@@ -128,7 +130,7 @@ export async function deleteCode(key: string): Promise<void> {
await redis.del(`${KEY_PREFIX}${key}`)
return
} catch (err) {
console.warn('[Redis] del 失败,降级内存:', err)
logger.warn({ err }, 'Redis del 失败,降级内存')
}
}
memoryStore.delete(key)
+4 -2
View File
@@ -1,3 +1,5 @@
import logger from './logger'
const required = ['JWT_SECRET', 'JWT_REFRESH_SECRET', 'ENCRYPTION_KEY']
const defaults: Record<string, string> = {
JWT_SECRET: 'dev-secret',
@@ -13,10 +15,10 @@ export function validateEnv(): void {
}
}
if (missing.length > 0 && process.env.NODE_ENV === 'production') {
console.error(`[FATAL] 以下环境变量未设置或使用了默认值,生产环境禁止启动: ${missing.join(', ')}`)
logger.fatal({ missing }, '以下环境变量未设置或使用了默认值,生产环境禁止启动')
process.exit(1)
}
if (missing.length > 0) {
console.warn(`[WARN] 以下环境变量使用了默认值,仅限开发环境: ${missing.join(', ')}`)
logger.warn({ missing }, '以下环境变量使用了默认值,仅限开发环境')
}
}
+23
View File
@@ -0,0 +1,23 @@
import pino from 'pino'
const isDev = process.env.NODE_ENV !== 'production'
const logger = pino({
level: process.env.LOG_LEVEL || (isDev ? 'debug' : 'info'),
transport: isDev
? {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'HH:MM:ss',
ignore: 'pid,hostname',
},
}
: undefined,
redact: {
paths: ['req.headers.authorization', 'req.headers.cookie', '*.password', '*.passwordHash', '*.token', '*.refreshToken'],
censor: '[REDACTED]',
},
})
export default logger
+8
View File
@@ -0,0 +1,8 @@
/**
* 统一分页参数解析,全局限制 pageSize ≤ 200,防止过量数据查询
*/
export function parsePagination(query: Record<string, any>): { page: number; pageSize: number } {
const page = Math.max(1, parseInt(query.page as string) || 1)
const pageSize = Math.min(Math.max(1, parseInt(query.pageSize as string) || 20), 200)
return { page, pageSize }
}
+2 -1
View File
@@ -1,5 +1,6 @@
import { AuthRequest } from './auth'
import prisma from '../lib/prisma'
import logger from '../lib/logger'
export async function auditLog(
req: AuthRequest,
@@ -22,6 +23,6 @@ export async function auditLog(
},
})
} catch (err) {
console.error('Audit log error:', err)
logger.error({ err, action, entity, entityId }, 'Audit log error')
}
}
+2 -1
View File
@@ -2,6 +2,7 @@ import { Request, Response, NextFunction } from 'express'
import { ZodError } from 'zod'
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'
import { AppError } from '../lib/AppError'
import logger from '../lib/logger'
export function errorHandler(err: unknown, _req: Request, res: Response, _next: NextFunction) {
if (err instanceof AppError) {
@@ -55,7 +56,7 @@ export function errorHandler(err: unknown, _req: Request, res: Response, _next:
})
}
console.error('Unhandled error:', err)
logger.error({ err }, 'Unhandled error')
return res.status(500).json({
success: false,
error: { code: 'INTERNAL_ERROR', message: '服务器内部错误' },
@@ -0,0 +1,142 @@
import { Router, Response, NextFunction } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import prisma from '../lib/prisma'
const router = Router()
// 保存(创建或更新)
router.post('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { verifierName, results, remarks, conclusionName, conclusionDate, conclusionResult, conclusionIssues, screenshots, signature1, signature2 } = req.body
if (!verifierName || !verifierName.trim()) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请输入验收人姓名' } })
}
const orgId = req.user!.orgId
const data = {
verifierName: verifierName.trim(),
results: results || {},
remarks: remarks || {},
conclusionName: conclusionName || null,
conclusionDate: conclusionDate || null,
conclusionResult: conclusionResult || null,
conclusionIssues: conclusionIssues || null,
screenshots: screenshots || undefined,
signature1: signature1 || undefined,
signature2: signature2 || undefined,
}
const record = await prisma.acceptanceTest.upsert({
where: { orgId_verifierName: { orgId, verifierName: verifierName.trim() } },
create: { ...data, orgId, createdBy: req.user!.id },
update: data,
})
res.json({ success: true, data: record })
} catch (err) {
next(err)
}
})
// 获取单个验收记录
router.get('/:verifierName', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const record = await prisma.acceptanceTest.findUnique({
where: {
orgId_verifierName: { orgId: req.user!.orgId, verifierName: req.params.verifierName },
},
})
if (!record) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '未找到保存记录' } })
}
res.json({ success: true, data: record })
} catch (err) {
next(err)
}
})
// 列表(所有验收记录摘要)
router.get('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const records = await prisma.acceptanceTest.findMany({
where: { orgId: req.user!.orgId },
select: {
id: true,
verifierName: true,
status: true,
conclusionResult: true,
conclusionDate: true,
updatedAt: true,
results: true,
},
orderBy: { updatedAt: 'desc' },
})
const list = records.map((r: any) => {
const vals = Object.values(r.results as any)
const pass = vals.filter((v: any) => v === 'pass').length
const total = vals.length
const tested = vals.filter((v: any) => v !== 'untested').length
return {
id: r.id,
verifierName: r.verifierName,
status: r.status,
conclusionResult: r.conclusionResult,
conclusionDate: r.conclusionDate,
updatedAt: r.updatedAt,
testedCount: tested,
passCount: pass,
totalCount: total,
}
})
res.json({ success: true, data: list })
} catch (err) {
next(err)
}
})
// 提交验收报告
router.post('/:verifierName/submit', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { conclusionResult, conclusionIssues } = req.body
const record = await prisma.acceptanceTest.findUnique({
where: {
orgId_verifierName: { orgId: req.user!.orgId, verifierName: req.params.verifierName },
},
})
if (!record) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '请先保存再提交' } })
}
const updated = await prisma.acceptanceTest.update({
where: { id: record.id },
data: {
status: 'SUBMITTED',
submittedAt: new Date(),
conclusionResult: conclusionResult || record.conclusionResult,
conclusionIssues: conclusionIssues || record.conclusionIssues,
},
})
res.json({ success: true, data: updated })
} catch (err) {
next(err)
}
})
// 删除
router.delete('/:verifierName', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
await prisma.acceptanceTest.delete({
where: {
orgId_verifierName: { orgId: req.user!.orgId, verifierName: req.params.verifierName },
},
})
res.json({ success: true })
} catch (err) {
next(err)
}
})
export default router
-1
View File
@@ -2,7 +2,6 @@ import { Router, Response, NextFunction } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { z } from 'zod'
import {
createAttendanceConfirmation,
batchCreateAttendanceConfirmations,
getAttendanceConfirmations,
confirmAttendance,
+1 -1
View File
@@ -1,7 +1,7 @@
import { Router, Response, NextFunction } from 'express'
import prisma from '../lib/prisma'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { getDashboardData, getMonthlyCalendar, getCostAnalysis, getComplianceScore, getHealthCheck, saveHealthCheckReport, getHealthCheckHistory, getAnnualValueReport, saveAnnualValueReport, getAnnualValueReportHistory } from '../services/risk.service'
import { getDashboardData, getMonthlyCalendar, getCostAnalysis, getHealthCheck, saveHealthCheckReport, getHealthCheckHistory, getAnnualValueReport, saveAnnualValueReport, getAnnualValueReportHistory } from '../services/risk.service'
import { z } from 'zod'
const router = Router()
@@ -1,7 +1,6 @@
import { Router, Response, NextFunction } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import prisma from '../lib/prisma'
import { renderTemplate } from '../services/template.service'
const router = Router()
-1
View File
@@ -255,7 +255,6 @@ router.get('/roster', authMiddleware, async (req: AuthRequest, res: Response, ne
const search = req.query.search as string | undefined
const status = req.query.status as string | undefined
const department = req.query.department as string | undefined
const contractStatus = req.query.contractStatus as string | undefined
const where: any = { orgId }
if (department) where.department = department
+1 -2
View File
@@ -324,7 +324,7 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
})
result.employees++
result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'success', message: '导入成功' })
result.details.push({ sheet: '员工信息', row: i + 2, name, employeeId: emp.id, status: 'success', message: '导入成功' })
} catch (e: any) {
const msg = e?.message || ''
if (msg.includes('Unique constraint')) {
@@ -720,7 +720,6 @@ router.post('/payroll', authMiddleware, upload.single('file'), async (req: AuthR
try {
if (!req.file) return res.status(400).json({ success: false, message: '请上传文件' })
const orgId = req.user!.orgId
const userId = req.user!.id
const batchId = req.body.batchId as string
if (!batchId) return res.status(400).json({ success: false, message: '缺少批次ID' })
+198
View File
@@ -0,0 +1,198 @@
import { Router, Response, NextFunction } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import prisma from '../lib/prisma'
import { parsePagination } from '../lib/pagination'
const router = Router()
// 休假申请列表
router.get('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const { status, leaveType, employeeId } = req.query
const { page, pageSize } = parsePagination(req.query)
const where: any = { orgId }
if (status) where.status = status
if (leaveType) where.leaveType = leaveType
if (employeeId) where.employeeId = employeeId
const total = await prisma.leaveRequest.count({ where })
const list = await prisma.leaveRequest.findMany({
where,
include: {
employee: { select: { id: true, name: true, department: true, position: true } },
},
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
})
res.json({ success: true, data: { list, total, page, pageSize } })
} catch (err) {
next(err)
}
})
// 创建休假申请
router.post('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const { employeeId, leaveType, startDate, endDate, days, reason, attachment } = req.body
if (!employeeId || !leaveType || !startDate || !endDate) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少必填字段' } })
}
const record = await prisma.leaveRequest.create({
data: {
orgId,
employeeId,
leaveType,
startDate: new Date(startDate),
endDate: new Date(endDate),
days: days || 1,
reason: reason || null,
attachment: attachment || null,
createdBy: req.user!.id,
},
include: {
employee: { select: { id: true, name: true, department: true, position: true } },
},
})
res.json({ success: true, data: record })
} catch (err) {
next(err)
}
})
// 审批(批准/驳回)
router.post('/:id/approve', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params
const { action, remark } = req.body // action: 'APPROVED' | 'REJECTED'
const orgId = req.user!.orgId
if (!['APPROVED', 'REJECTED'].includes(action)) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '无效的审批操作' } })
}
const existing = await prisma.leaveRequest.findFirst({ where: { id, orgId } })
if (!existing) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '申请不存在' } })
}
if (existing.status !== 'PENDING') {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '该申请已处理' } })
}
const updated = await prisma.leaveRequest.update({
where: { id },
data: {
status: action,
approverId: req.user!.id,
approvedAt: new Date(),
approveRemark: remark || null,
},
include: {
employee: { select: { id: true, name: true, department: true, position: true } },
},
})
// 如果批准,自动创建 LeaveRecord
if (action === 'APPROVED') {
await prisma.leaveRecord.create({
data: {
orgId,
employeeId: existing.employeeId,
leaveType: existing.leaveType,
startDate: existing.startDate,
endDate: existing.endDate,
days: existing.days,
reason: existing.reason || '',
remark: `休假审批通过(申请ID: ${id}`,
createdBy: req.user!.id,
},
})
}
res.json({ success: true, data: updated })
} catch (err) {
next(err)
}
})
// 撤回申请
router.post('/:id/cancel', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params
const orgId = req.user!.orgId
const existing = await prisma.leaveRequest.findFirst({ where: { id, orgId } })
if (!existing) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '申请不存在' } })
}
if (existing.status !== 'PENDING') {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '已处理的申请不可撤回' } })
}
const updated = await prisma.leaveRequest.update({
where: { id },
data: { status: 'CANCELLED' },
})
res.json({ success: true, data: updated })
} catch (err) {
next(err)
}
})
// 删除申请(仅待审批状态可删)
router.delete('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params
const orgId = req.user!.orgId
const existing = await prisma.leaveRequest.findFirst({ where: { id, orgId } })
if (!existing) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '申请不存在' } })
}
if (existing.status !== 'PENDING') {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '已处理的申请不可删除' } })
}
await prisma.leaveRequest.delete({ where: { id } })
res.json({ success: true })
} catch (err) {
next(err)
}
})
// 统计
router.get('/stats', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const { month } = req.query
const where: any = { orgId }
if (month) {
const start = new Date(`${month}-01`)
const end = new Date(`${month}-31T23:59:59`)
where.startDate = { gte: start, lte: end }
}
const [pending, approved, rejected, cancelled, total] = await Promise.all([
prisma.leaveRequest.count({ where: { ...where, status: 'PENDING' } }),
prisma.leaveRequest.count({ where: { ...where, status: 'APPROVED' } }),
prisma.leaveRequest.count({ where: { ...where, status: 'REJECTED' } }),
prisma.leaveRequest.count({ where: { ...where, status: 'CANCELLED' } }),
prisma.leaveRequest.count({ where }),
])
res.json({ success: true, data: { pending, approved, rejected, cancelled, total } })
} catch (err) {
next(err)
}
})
export default router
+7 -6
View File
@@ -132,9 +132,13 @@ router.post('/check-contracts', async (req: AuthRequest, res: Response, next: Ne
})
// 测试通知渠道
const testChannelSchema = z.object({
channel: z.enum(['wechat', 'email']),
})
router.post('/test', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { channel } = req.body as { channel: 'wechat' | 'email' }
const { channel } = testChannelSchema.parse(req.body)
const setting = await prisma.notificationSetting.findUnique({ where: { orgId: req.user!.orgId } })
if (!setting) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '通知设置不存在' } })
@@ -154,12 +158,9 @@ router.post('/test', async (req: AuthRequest, res: Response, next: NextFunction)
} catch (e: any) {
res.json({ success: false, error: { code: 'TEST_FAILED', message: `发送失败: ${e?.message || '网络错误'}` } })
}
} else if (channel === 'email') {
if (!setting.email) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '未配置通知邮箱' } })
// 邮件发送(开发阶段仅返回成功)
res.json({ success: true, data: { message: `测试邮件已发送到 ${setting.email}` } })
} else {
res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '不支持的通知渠道' } })
if (!setting.email) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '未配置通知邮箱' } })
res.json({ success: true, data: { message: `测试邮件已发送到 ${setting.email}` } })
}
} catch (err) {
next(err)
+39 -1
View File
@@ -504,7 +504,45 @@ router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res
},
})
res.json({ success: true, data: updated })
res.json({ success: true, data: updated, taxBreakdown: calcResult.taxBreakdown })
} catch (err) {
next(err)
}
})
// 获取条目个税计算明细
router.get('/batches/:batchId/entries/:employeeId/tax-detail', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { batchId, employeeId } = req.params
const orgId = req.user!.orgId
const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } })
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
const entry = await prisma.batchEntry.findUnique({
where: { batchId_employeeId: { batchId, employeeId } },
})
if (!entry) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '条目不存在' } })
const inputs = {
baseSalary: entry.baseSalary,
overtimePay: entry.overtimePay,
allowance: entry.allowance,
deduction: entry.deduction,
bonus: entry.bonus,
}
const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, inputs, batch.type)
const taxBreakdown = calcResult.taxBreakdown || {}
// 加入社保公积金系统计算值 vs 实际值对比
taxBreakdown.systemSocialEmp = calcResult.systemSocialEmp
taxBreakdown.systemSocialOrg = calcResult.systemSocialOrg
taxBreakdown.systemHousingEmp = calcResult.systemHousingEmp
taxBreakdown.systemHousingOrg = calcResult.systemHousingOrg
taxBreakdown.actualSocialEmp = entry.socialEmp
taxBreakdown.actualSocialOrg = entry.socialOrg
taxBreakdown.actualHousingEmp = entry.housingEmp
taxBreakdown.actualHousingOrg = entry.housingOrg
res.json({ success: true, data: taxBreakdown })
} catch (err) {
next(err)
}
+8 -30
View File
@@ -6,7 +6,8 @@
import { Router } from 'express'
import prisma from '../lib/prisma'
import { AuthRequest, authMiddleware, platformAdminMiddleware } from '../middleware/auth'
import { loginLimiter } from '../middleware/rateLimit'
import { parsePagination } from '../lib/pagination'
import { createOrgSchema, updateOrgSchema, updateOrgAdminSchema, createPlatformAdminSchema } from '../schemas/platform.schema'
const router = Router()
@@ -88,8 +89,7 @@ router.get('/dashboard', async (_req: AuthRequest, res, next) => {
*/
router.get('/orgs', async (req: AuthRequest, res, next) => {
try {
const page = parseInt(req.query.page as string) || 1
const pageSize = parseInt(req.query.pageSize as string) || 20
const { page, pageSize } = parsePagination(req.query)
const search = (req.query.search as string) || ''
const planFilter = (req.query.plan as string) || ''
@@ -154,19 +154,7 @@ router.post('/orgs', async (req: AuthRequest, res, next) => {
const {
name, plan, maxEmployees, city, contactName, contactPhone,
adminName, adminPhone, adminPassword,
} = req.body as {
name: string; plan?: string; maxEmployees?: number
city?: string; contactName?: string; contactPhone?: string
adminName?: string; adminPhone: string; adminPassword: string
}
if (!name || !adminPhone || !adminPassword) {
return res.status(400).json({ success: false, error: { code: 'VALIDATION', message: '企业名称、管理员手机号、密码不能为空' } })
}
if (adminPassword.length < 8) {
return res.status(400).json({ success: false, error: { code: 'VALIDATION', message: '密码至少8位' } })
}
} = createOrgSchema.parse(req.body)
const existing = await prisma.user.findUnique({ where: { phone: adminPhone } })
if (existing) {
@@ -244,10 +232,7 @@ router.get('/orgs/:id', async (req: AuthRequest, res, next) => {
*/
router.put('/orgs/:id', async (req: AuthRequest, res, next) => {
try {
const { name, plan, maxEmployees, city, contactName, contactPhone } = req.body as {
name?: string; plan?: string; maxEmployees?: number;
city?: string; contactName?: string; contactPhone?: string
}
const { name, plan, maxEmployees, city, contactName, contactPhone } = updateOrgSchema.parse(req.body)
const updateData: any = {}
if (name) updateData.name = name
@@ -274,9 +259,7 @@ router.put('/orgs/:id', async (req: AuthRequest, res, next) => {
*/
router.put('/orgs/:id/admin', async (req: AuthRequest, res, next) => {
try {
const { adminName, adminPhone, adminPassword } = req.body as {
adminName?: string; adminPhone?: string; adminPassword?: string
}
const { adminName, adminPhone, adminPassword } = updateOrgAdminSchema.parse(req.body)
// 找到该企业的 ADMIN 角色用户(第一个管理员)
const admin = await prisma.user.findFirst({
@@ -335,8 +318,7 @@ router.delete('/orgs/:id', async (req: AuthRequest, res, next) => {
*/
router.get('/users', async (req: AuthRequest, res, next) => {
try {
const page = parseInt(req.query.page as string) || 1
const pageSize = parseInt(req.query.pageSize as string) || 20
const { page, pageSize } = parsePagination(req.query)
const search = (req.query.search as string) || ''
const orgId = (req.query.orgId as string) || ''
@@ -432,11 +414,7 @@ router.get('/admins', async (_req: AuthRequest, res, next) => {
*/
router.post('/admins', async (req: AuthRequest, res, next) => {
try {
const { name, phone, password } = req.body as { name: string; phone: string; password: string }
if (!name || !phone || !password) {
return res.status(400).json({ success: false, error: { code: 'VALIDATION', message: '姓名、手机号、密码不能为空' } })
}
const { name, phone, password } = createPlatformAdminSchema.parse(req.body)
const existing = await prisma.user.findUnique({ where: { phone } })
if (existing) {
+60
View File
@@ -838,4 +838,64 @@ router.post('/resignation/:id/withdraw', portalAuth, async (req: any, res, next)
} catch (err) { next(err) }
})
// ========== 员工端:休假申请 ==========
// 查看自己的休假申请列表
router.get('/leaves', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
const list = await prisma.leaveRequest.findMany({
where: { employeeId, orgId },
orderBy: { createdAt: 'desc' },
})
res.json({ success: true, data: list })
} catch (err) { next(err) }
})
// 提交休假申请
router.post('/leaves', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
const { leaveType, startDate, endDate, days, reason } = req.body
if (!leaveType || !startDate || !endDate) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少必填字段' } })
}
const record = await prisma.leaveRequest.create({
data: {
orgId,
employeeId,
leaveType,
startDate: new Date(startDate),
endDate: new Date(endDate),
days: days || 1,
reason: reason || null,
createdBy: employeeId,
},
})
res.json({ success: true, data: record })
} catch (err) { next(err) }
})
// 撤回休假申请(仅待审批可撤回)
router.post('/leaves/:id/cancel', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
const record = await prisma.leaveRequest.findFirst({
where: { id: req.params.id, employeeId, orgId },
})
if (!record) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '申请不存在' } })
}
if (record.status !== 'PENDING') {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '已处理的申请不可撤回' } })
}
const updated = await prisma.leaveRequest.update({
where: { id: record.id },
data: { status: 'CANCELLED' },
})
res.json({ success: true, data: updated })
} catch (err) { next(err) }
})
export default router
+7 -5
View File
@@ -5,6 +5,7 @@
import { Router, Response, NextFunction } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import prisma from '../lib/prisma'
import { parsePagination } from '../lib/pagination'
import {
createSpecialStatus,
updateSpecialStatus,
@@ -13,6 +14,7 @@ import {
STATUS_TYPES,
getAlertLevel,
} from '../services/special-status.service'
import { createSpecialStatusSchema, updateSpecialStatusSchema } from '../schemas/special-status.schema'
const router = Router()
@@ -22,8 +24,7 @@ const router = Router()
router.get('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const page = parseInt(req.query.page as string) || 1
const pageSize = parseInt(req.query.pageSize as string) || 20
const { page, pageSize } = parsePagination(req.query)
const type = (req.query.type as string) || ''
const status = (req.query.status as string) || ''
const search = (req.query.search as string) || ''
@@ -97,7 +98,8 @@ router.get('/:id', authMiddleware, async (req: AuthRequest, res: Response, next:
*/
router.post('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const record = await createSpecialStatus(req.user!.orgId, req.user!.id, req.body)
const data = createSpecialStatusSchema.parse(req.body)
const record = await createSpecialStatus(req.user!.orgId, req.user!.id, data)
res.json({ success: true, data: record })
} catch (err: any) {
if (err.code) {
@@ -112,7 +114,8 @@ router.post('/', authMiddleware, async (req: AuthRequest, res: Response, next: N
*/
router.put('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const record = await updateSpecialStatus(req.user!.orgId, req.user!.id, req.params.id, req.body)
const data = updateSpecialStatusSchema.parse(req.body)
const record = await updateSpecialStatus(req.user!.orgId, req.user!.id, req.params.id, data)
res.json({ success: true, data: record })
} catch (err: any) {
if (err.code) {
@@ -167,7 +170,6 @@ router.get('/stats/overview', authMiddleware, async (req: AuthRequest, res: Resp
])
// 预警统计
const now = new Date()
const soon7 = new Date()
soon7.setDate(soon7.getDate() + 7)
const soon30 = new Date()
+13 -24
View File
@@ -1,7 +1,7 @@
import { Router } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { auditLog } from '../middleware/auditLog'
import { terminationChecklistSchema } from '../schemas/termination.schema'
import { terminationChecklistSchema, resignationSchema, batchTerminatePreviewSchema, batchTerminateSchema, createTerminationDraftSchema, updateTerminationDraftSchema } from '../schemas/termination.schema'
import { createTermination, createResignation, revokeTermination, getTerminations, getChecklistForReason, assessRisk, batchTerminatePreview, batchTerminate, createDraft, updateDraft, submitForApproval, approveTermination, rejectTermination, executeTermination, cancelTermination, getDrafts, getTerminationDetail, getDefaultHandoverItems, validateTerminationStep } from '../services/termination.service'
import prisma from '../lib/prisma'
import { createEvidence } from '../services/evidence.service'
@@ -80,10 +80,7 @@ router.post('/', authMiddleware, async (req: AuthRequest, res, next) => {
router.post('/resignation', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { employeeId, terminationDate, resignationReason, remark } = req.body
if (!employeeId || !terminationDate) {
return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '缺少必填字段' } })
}
const { employeeId, terminationDate, resignationReason, remark } = resignationSchema.parse(req.body)
const result = await createResignation(req.user!.orgId, req.user!.id, { employeeId, terminationDate, resignationReason, remark })
await auditLog(req, 'RESIGN', 'EMPLOYEE', employeeId, { resignationReason })
res.json({ success: true, data: result })
@@ -120,12 +117,7 @@ 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 { items } = batchTerminatePreviewSchema.parse(req.body)
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) {
@@ -136,12 +128,7 @@ router.post('/batch/preview', authMiddleware, async (req: AuthRequest, res, next
// 批量解聘执行
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 { items } = batchTerminateSchema.parse(req.body)
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 })
@@ -192,15 +179,16 @@ router.get('/handover-template', authMiddleware, async (req: AuthRequest, res) =
// 创建草稿
router.post('/draft', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const result = await createDraft(req.user!.orgId, req.user!.id, req.body)
const emp = await prisma.employee.findFirst({ where: { id: req.body.employeeId }, select: { name: true, department: true } })
const data = createTerminationDraftSchema.parse(req.body)
const result = await createDraft(req.user!.orgId, req.user!.id, data)
const emp = await prisma.employee.findFirst({ where: { id: data.employeeId }, select: { name: true, department: true } })
await auditLog(req, 'CREATE_DRAFT', 'TERMINATION_RECORD', result.id, {
employeeName: emp?.name || '',
department: emp?.department || '',
reason: req.body.reason || '',
type: req.body.type || 'TERMINATION',
terminationDate: req.body.terminationDate || '',
compensation: req.body.compensation || 0,
reason: data.reason || '',
type: data.type || 'TERMINATION',
terminationDate: data.terminationDate || '',
compensation: data.compensation || 0,
})
res.json({ success: true, data: result })
} catch (err: any) {
@@ -214,7 +202,8 @@ router.post('/draft', authMiddleware, async (req: AuthRequest, res, next) => {
// 更新草稿
router.put('/draft/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const result = await updateDraft(req.user!.orgId, req.params.id, req.user!.id, req.body)
const data = updateTerminationDraftSchema.parse(req.body)
const result = await updateDraft(req.user!.orgId, req.params.id, req.user!.id, data)
res.json({ success: true, data: result })
} catch (err: any) {
if (err?.code === 'CONFLICT' || err?.code === 'NOT_FOUND') {
+13 -12
View File
@@ -1,17 +1,17 @@
import { Router, Response, NextFunction } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import prisma from '../lib/prisma'
import { parsePagination } from '../lib/pagination'
import { executeWorkProcess, generateDocument, PROCESS_TYPES, PROCESS_STATUS } from '../services/work-process.service'
import { createWorkProcessSchema, updateWorkProcessSchema } from '../schemas/work-process.schema'
const router = Router()
// 创建办理(含草稿)
router.post('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { type, title, employeeId, formData, status = 'DRAFT', remark } = req.body
if (!type || !PROCESS_TYPES[type]) {
return res.status(400).json({ success: false, error: { code: 'INVALID_TYPE', message: '无效的流程类型' } })
}
const data = createWorkProcessSchema.parse(req.body)
const { type, title, employeeId, formData, status, remark } = data
const process = await (prisma as any).workProcess.create({
data: {
orgId: req.user!.orgId,
@@ -33,7 +33,8 @@ router.post('/', authMiddleware, async (req: AuthRequest, res: Response, next: N
// 列表查询
router.get('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { type, status, page = '1', pageSize = '20' } = req.query
const { type, status } = req.query
const { page, pageSize } = parsePagination(req.query)
const where: any = { orgId: req.user!.orgId }
if (type) where.type = type
if (status) where.status = status
@@ -42,10 +43,10 @@ router.get('/', authMiddleware, async (req: AuthRequest, res: Response, next: Ne
where,
include: { employee: { select: { id: true, name: true, department: true } } },
orderBy: { createdAt: 'desc' },
skip: (Number(page) - 1) * Number(pageSize),
take: Number(pageSize),
skip: (page - 1) * pageSize,
take: pageSize,
})
res.json({ success: true, data: { items, total, page: Number(page), pageSize: Number(pageSize) } })
res.json({ success: true, data: { items, total, page, pageSize } })
} catch (err) {
next(err)
}
@@ -79,7 +80,7 @@ router.patch('/:id', authMiddleware, async (req: AuthRequest, res: Response, nex
if (existing.status !== 'DRAFT') {
return res.status(400).json({ success: false, error: { code: 'NOT_DRAFT', message: '仅草稿状态可编辑' } })
}
const { title, employeeId, formData, remark } = req.body
const { title, employeeId, formData, remark } = updateWorkProcessSchema.parse(req.body)
const updated = await (prisma as any).workProcess.update({
where: { id: req.params.id },
data: {
@@ -116,7 +117,7 @@ router.post('/:id/submit', authMiddleware, async (req: AuthRequest, res: Respons
}
// 生成文书
const org = await prisma.organization.findUnique({ where: { id: req.user!.orgId } })
const doc = generateDocument(process.type, process.formData, org?.name || '')
const doc = await generateDocument(process.type, process.formData, org?.name || '')
const documents = doc.content ? [doc] : []
const updated = await (prisma as any).workProcess.update({
where: { id: process.id },
@@ -151,7 +152,7 @@ router.post('/:id/approve', authMiddleware, async (req: AuthRequest, res: Respon
return res.status(400).json({ success: false, error: { code: 'EXEC_FAILED', message: `执行失败:${execErr?.message || '未知错误'}` } })
}
const org = await prisma.organization.findUnique({ where: { id: req.user!.orgId } })
const doc = generateDocument(process.type, process.formData, org?.name || '')
const doc = await generateDocument(process.type, process.formData, org?.name || '')
const documents = doc.content ? [doc] : []
const updated = await (prisma as any).workProcess.update({
where: { id: process.id },
@@ -247,7 +248,7 @@ router.get('/:id/preview', authMiddleware, async (req: AuthRequest, res: Respons
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '办理记录不存在' } })
}
const org = await prisma.organization.findUnique({ where: { id: req.user!.orgId } })
const doc = generateDocument(process.type, process.formData, org?.name || '')
const doc = await generateDocument(process.type, process.formData, org?.name || '')
res.json({ success: true, data: doc })
} catch (err) {
next(err)
+34
View File
@@ -0,0 +1,34 @@
import { z } from 'zod'
export const createOrgSchema = z.object({
name: z.string().min(2, '企业名称至少2个字').max(50, '企业名称最多50个字'),
plan: z.enum(['FREE', 'PRO', 'ENTERPRISE']).default('FREE'),
maxEmployees: z.number().int().min(1).max(100000).default(20),
city: z.string().max(50).optional(),
contactName: z.string().max(30).optional(),
contactPhone: z.string().regex(/^1[3-9]\d{9}$/).optional(),
adminName: z.string().max(30).optional(),
adminPhone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
adminPassword: z.string().min(8, '密码至少8位').max(32, '密码最多32位'),
})
export const updateOrgSchema = z.object({
name: z.string().min(2).max(50).optional(),
plan: z.enum(['FREE', 'PRO', 'ENTERPRISE']).optional(),
maxEmployees: z.number().int().min(1).max(100000).optional(),
city: z.string().max(50).optional().nullable(),
contactName: z.string().max(30).optional().nullable(),
contactPhone: z.string().regex(/^1[3-9]\d{9}$/).optional().nullable(),
})
export const updateOrgAdminSchema = z.object({
adminName: z.string().max(30).optional(),
adminPhone: z.string().regex(/^1[3-9]\d{9}$/).optional(),
adminPassword: z.string().min(8, '密码至少8位').max(32).optional(),
})
export const createPlatformAdminSchema = z.object({
name: z.string().min(1, '姓名不能为空').max(30),
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
password: z.string().min(8, '密码至少8位').max(32, '密码最多32位'),
})
@@ -0,0 +1,40 @@
import { z } from 'zod'
export const createSpecialStatusSchema = z.object({
employeeId: z.string().min(1, '员工ID不能为空'),
type: z.enum([
'PREGNANCY', 'WORK_INJURY', 'MEDICAL_PERIOD', 'OTHER',
], { errorMap: () => ({ message: '无效的特殊状态类型' }) }),
status: z.enum(['ACTIVE', 'PENDING', 'RESOLVED']).default('ACTIVE'),
startDate: z.string().optional().nullable(),
endDate: z.string().optional().nullable(),
expectedDueDate: z.string().optional().nullable(),
injuryDate: z.string().optional().nullable(),
injuryDescription: z.string().max(500).optional().nullable(),
certificationDate: z.string().optional().nullable(),
certificationNo: z.string().max(50).optional().nullable(),
disabilityLevel: z.string().max(20).optional().nullable(),
assessmentDate: z.string().optional().nullable(),
medicalMonths: z.number().int().min(1).max(36).optional().nullable(),
description: z.string().max(500).optional().nullable(),
attachments: z.array(z.any()).optional().nullable(),
reminderDate: z.string().optional().nullable(),
})
export const updateSpecialStatusSchema = z.object({
type: z.enum(['PREGNANCY', 'WORK_INJURY', 'MEDICAL_PERIOD', 'OTHER']).optional(),
status: z.enum(['ACTIVE', 'PENDING', 'RESOLVED']).optional(),
startDate: z.string().optional().nullable(),
endDate: z.string().optional().nullable(),
expectedDueDate: z.string().optional().nullable(),
injuryDate: z.string().optional().nullable(),
injuryDescription: z.string().max(500).optional().nullable(),
certificationDate: z.string().optional().nullable(),
certificationNo: z.string().max(50).optional().nullable(),
disabilityLevel: z.string().max(20).optional().nullable(),
assessmentDate: z.string().optional().nullable(),
medicalMonths: z.number().int().min(1).max(36).optional().nullable(),
description: z.string().max(500).optional().nullable(),
attachments: z.array(z.any()).optional().nullable(),
reminderDate: z.string().optional().nullable(),
})
+42
View File
@@ -13,3 +13,45 @@ export const terminationQuerySchema = z.object({
page: z.coerce.number().min(1).default(1),
pageSize: z.coerce.number().min(1).max(50).default(20),
})
export const resignationSchema = z.object({
employeeId: z.string().min(1, '员工ID不能为空'),
terminationDate: z.string().min(1, '离职日期不能为空'),
resignationReason: z.string().max(200).optional(),
remark: z.string().max(500).optional(),
})
export const batchTerminatePreviewSchema = z.object({
items: z.array(z.object({
employeeId: z.string().min(1),
reason: z.string().min(1, '解聘原因不能为空'),
terminationDate: z.string().min(1, '解聘日期不能为空'),
})).min(1, '至少选择一名员工'),
})
export const batchTerminateSchema = z.object({
items: z.array(z.object({
employeeId: z.string().min(1),
reason: z.string().min(1, '解聘原因不能为空'),
terminationDate: z.string().min(1, '解聘日期不能为空'),
compensation: z.number().min(0).optional(),
})).min(1, '至少选择一名员工'),
})
export const createTerminationDraftSchema = z.object({
employeeId: z.string().min(1, '员工ID不能为空'),
type: z.enum(['TERMINATION', 'RESIGNATION']).optional(),
reason: z.string().max(200).optional(),
terminationDate: z.string().optional(),
compensation: z.number().min(0).optional(),
handoverItems: z.array(z.any()).optional(),
remark: z.string().max(500).optional(),
})
export const updateTerminationDraftSchema = z.object({
reason: z.string().max(200).optional(),
terminationDate: z.string().optional(),
compensation: z.number().min(0).optional(),
handoverItems: z.array(z.any()).optional(),
remark: z.string().max(500).optional(),
})
@@ -0,0 +1,21 @@
import { z } from 'zod'
export const createWorkProcessSchema = z.object({
type: z.enum([
'HIRE', 'ONBOARD', 'CUSTOM_CONTRACT', 'INFO_SUBMIT', 'CONFIRM',
'CHANGE', 'RENEW', 'SUSPEND', 'INCOME_CERT', 'TERMINATE',
'RESCIND', 'LEAVING_CERT', 'FLEXIBLE',
]),
title: z.string().max(100).optional(),
employeeId: z.string().optional().nullable(),
formData: z.record(z.any()).optional(),
status: z.enum(['DRAFT', 'COMPLETED']).default('DRAFT'),
remark: z.string().max(500).optional().nullable(),
})
export const updateWorkProcessSchema = z.object({
title: z.string().max(100).optional(),
employeeId: z.string().optional().nullable(),
formData: z.record(z.any()).optional(),
remark: z.string().max(500).optional().nullable(),
})
+28 -1
View File
@@ -205,6 +205,12 @@ export async function calcBatchEntry(
housingOrg = Math.max(0, fullHousingOrg - deductedHousingOrg)
}
// 保存系统计算值(覆盖前)
const systemSocialEmp = socialEmp
const systemSocialOrg = socialOrg
const systemHousingEmp = housingEmp
const systemHousingOrg = housingOrg
// 手动覆盖社保值
if (options?.overrideSocial) {
if (options.overrideSocial.socialEmp !== undefined) socialEmp = options.overrideSocial.socialEmp
@@ -229,9 +235,11 @@ export async function calcBatchEntry(
// 个税计算
let tax = 0
let taxBreakdown: any = null
if (batchType === 'BONUS') {
// 年终奖单独计税
tax = calcBonusTax(inputs.bonus)
taxBreakdown = { method: '单独计税(年终奖)', bonus: inputs.bonus, tax }
} else {
// 累计预扣法(补偿金也走累计预扣,但无社保公积金扣除)
const year = month.slice(0, 4)
@@ -252,8 +260,22 @@ export async function calcBatchEntry(
const ytdHousingEmp = archivedEntries.reduce((s, e) => s + e.housingEmp, 0) + housingEmp
const ytdSpecialDeduction = employee.specialDeduction * Number(month.slice(5, 7))
const ytdTaxDeducted = archivedEntries.reduce((s, e) => s + e.tax, 0)
const ytdTaxableIncome = Math.max(0, ytdIncome - 5000 * Number(month.slice(5, 7)) - ytdSocialEmp - ytdHousingEmp - ytdSpecialDeduction)
const deductionAmount = 5000 * Number(month.slice(5, 7))
const ytdTaxableIncome = Math.max(0, ytdIncome - deductionAmount - ytdSocialEmp - ytdHousingEmp - ytdSpecialDeduction)
tax = calcCumulativeTax(ytdTaxableIncome, ytdTaxDeducted)
taxBreakdown = {
method: '累计预扣法',
month: Number(month.slice(5, 7)),
ytdIncome,
deductionAmount,
ytdSocialEmp,
ytdHousingEmp,
ytdSpecialDeduction,
ytdTaxableIncome,
ytdTaxDeducted,
currentMonthTax: tax,
archivedCount: archivedEntries.length,
}
}
const netPay = totalPay - socialEmp - housingEmp - tax
@@ -263,7 +285,12 @@ export async function calcBatchEntry(
socialOrg: Math.round(socialOrg * 100) / 100,
housingEmp: Math.round(housingEmp * 100) / 100,
housingOrg: Math.round(housingOrg * 100) / 100,
systemSocialEmp: Math.round(systemSocialEmp * 100) / 100,
systemSocialOrg: Math.round(systemSocialOrg * 100) / 100,
systemHousingEmp: Math.round(systemHousingEmp * 100) / 100,
systemHousingOrg: Math.round(systemHousingOrg * 100) / 100,
tax,
taxBreakdown,
totalPay: Math.round(totalPay * 100) / 100,
netPay: Math.round(netPay * 100) / 100,
}
+2 -11
View File
@@ -666,7 +666,7 @@ export async function getDashboardData(orgId: string) {
const yearEnd = new Date(now.getFullYear(), 11, 31, 23, 59, 59)
const [
employeeCount, highRisks, pendingRisks, riskItems, resolvedItems,
employeeCount, _highRisks, _pendingRisks, riskItems, resolvedItems,
overtimeRecords, payslips, batchEntries, socialConfig, housingConfig,
monthContracts, monthTerminations, monthDisciplinary, monthAttendance,
monthSeverancePay,
@@ -1217,17 +1217,13 @@ export async function getCostAnalysis(orgId: string, month: string) {
const monthNum = parseInt(month.slice(5, 7))
// 当月数据
const currentMonthStart = new Date(year, monthNum - 1, 1)
const currentMonthEnd = new Date(year, monthNum, 0, 23, 59, 59)
// 上月(环比)
const prevMonthStart = new Date(year, monthNum - 2, 1)
const prevMonthEnd = new Date(year, monthNum - 1, 0, 23, 59, 59)
const prevMonthStr = `${prevMonthStart.getFullYear()}-${String(prevMonthStart.getMonth() + 1).padStart(2, '0')}`
// 去年同月(同比)
const lastYearMonthStart = new Date(year - 1, monthNum - 1, 1)
const lastYearMonthEnd = new Date(year - 1, monthNum, 0, 23, 59, 59)
const lastYearMonthStr = `${lastYearMonthStart.getFullYear()}-${String(lastYearMonthStart.getMonth() + 1).padStart(2, '0')}`
// 获取各月归档批次汇总(按员工去重,与 getDashboardData 口径一致)
@@ -1802,7 +1798,7 @@ export async function getAnnualValueReport(orgId: string, year: number) {
const [
risksResolved,
lossAvoidedAgg,
_lossAvoidedAgg,
aiConversations,
aiReviews,
contractsSigned,
@@ -2043,11 +2039,6 @@ export async function getAnnualValueReport(orgId: string, year: number) {
// 总价值 = 规避损失 + 节约成本
const totalValue = adjustedLossAvoided + costSaved
// ROI = 总价值 / 系统成本(年费 12000 元),上限 9999%
const systemCost = 12000
const rawRoi = systemCost > 0 ? Math.round((totalValue / systemCost) * 100) : 0
const roi = Math.min(rawRoi, 9999)
const metrics = {
risksResolved,
lossAvoided,
+21 -6
View File
@@ -1,8 +1,7 @@
import prisma from '../lib/prisma'
import { encrypt } from '../lib/crypto'
import crypto from 'crypto'
import { createDraft as createTerminationDraft, executeTermination, createResignation } from './termination.service'
import { createEmployee, addContract, batchRenew } from './contract.service'
import { createDraft as createTerminationDraft, executeTermination } from './termination.service'
import { createEmployee, addContract } from './contract.service'
import { runRiskDetection } from './risk.service'
// 13类流程定义
@@ -67,7 +66,7 @@ export async function executeWorkProcess(processId: string, type: string, formDa
return { employeeId }
}
case 'CONFIRM': {
const { employeeId, confirmDate, regularSalary } = formData
const { employeeId, regularSalary } = formData
if (employeeId) {
if (regularSalary) {
await prisma.employee.update({
@@ -196,7 +195,7 @@ export async function executeWorkProcess(processId: string, type: string, formDa
return {}
}
case 'FLEXIBLE': {
const { name, phone, idCardNumber, department, agreementStartDate, agreementEndDate, payMethod } = formData
const { name, phone, idCardNumber, department, agreementStartDate, agreementEndDate } = formData
const empResult = await createEmployee(orgId, userId, {
name,
department: department || '灵活用工',
@@ -244,7 +243,23 @@ export async function executeWorkProcess(processId: string, type: string, formDa
}
// 生成文书预览
export function generateDocument(type: string, formData: any, orgName: string): { name: string; content: string } {
export async function generateDocument(type: string, formData: any, orgName: string): Promise<{ name: string; content: string }> {
// 如果指定了企业自定义模板,使用企业模板渲染
if (formData.enterpriseTemplateId) {
const tpl = await (prisma as any).enterpriseTemplate.findFirst({
where: { id: formData.enterpriseTemplateId },
})
if (tpl) {
let content = tpl.content
// 替换变量 {{var}}
const allVars: Record<string, string> = { ...formData, companyName: orgName }
for (const [key, value] of Object.entries(allVars)) {
content = content.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), String(value ?? ''))
}
return { name: `${tpl.name}.doc`, content }
}
}
const templates: Record<string, (data: any, org: string) => string> = {
INCOME_CERT: (data, org) => `收入证明