From 99c7ba776fced601299f0de542ede6536dae30db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=84=A6=E9=BE=99=E8=A8=80?= Date: Thu, 18 Jun 2026 13:21:20 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E6=AD=A5=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 1 + .gitignore | 10 + .idea/.gitignore | 10 + .idea/Financial_System.iml | 10 + .idea/inspectionProfiles/Project_Default.xml | 25 + .../inspectionProfiles/profiles_settings.xml | 6 + .idea/misc.xml | 7 + .idea/modules.xml | 8 + .idea/vcs.xml | 6 + README.md | 316 +++ config/app_settings.json | 47 + config/salary_rules.example.json | 36 + financial_system/__init__.py | 5 + financial_system/api/__init__.py | 3 + financial_system/api/app.py | 138 + financial_system/api/dependencies.py | 209 ++ financial_system/api/request_meta.py | 24 + financial_system/api/routers/__init__.py | 1 + financial_system/api/routers/auth.py | 266 ++ financial_system/api/routers/commissions.py | 191 ++ financial_system/api/routers/configs.py | 101 + financial_system/api/routers/employees.py | 199 ++ financial_system/api/routers/health.py | 10 + .../api/routers/operation_logs.py | 57 + financial_system/api/routers/organization.py | 176 ++ financial_system/api/routers/payroll.py | 239 ++ financial_system/api/routers/reports.py | 41 + .../api/routers/salary_profiles.py | 80 + financial_system/api/schemas/__init__.py | 43 + financial_system/api/schemas/auth.py | 168 ++ financial_system/api/schemas/commission.py | 78 + financial_system/api/schemas/employee.py | 90 + financial_system/api/schemas/operation_log.py | 62 + financial_system/api/schemas/organization.py | 87 + financial_system/api/schemas/payroll.py | 190 ++ financial_system/api/schemas/report.py | 75 + financial_system/api/schemas/salary_config.py | 66 + .../api/schemas/salary_profile.py | 76 + financial_system/cli/__init__.py | 5 + financial_system/cli/payroll.py | 61 + financial_system/core/__init__.py | 1 + financial_system/core/config.py | 115 + financial_system/core/logger.py | 101 + financial_system/core/permissions.py | 139 + financial_system/core/security.py | 98 + financial_system/core/settings.py | 147 + financial_system/database/__init__.py | 3 + financial_system/database/orm.py | 871 ++++++ financial_system/database/session.py | 239 ++ financial_system/database/sql/init_mysql.sql | 613 +++++ financial_system/database/sql/schema.sql | 489 ++++ financial_system/database/sql/seed.sql | 41 + .../sql/upgrade_20260616_employee_salary.sql | 55 + .../sql/upgrade_20260616_operation_logs.sql | 32 + .../sql/upgrade_20260616_user_profile.sql | 71 + .../sql/upgrade_20260617_payroll_modules.sql | 362 +++ financial_system/domain/__init__.py | 1 + financial_system/domain/calculator.py | 227 ++ financial_system/domain/models.py | 115 + financial_system/integrations/__init__.py | 1 + financial_system/integrations/dingtalk.py | 193 ++ financial_system/io/__init__.py | 1 + financial_system/io/exporter.py | 194 ++ financial_system/io/parser.py | 168 ++ financial_system/io/storage.py | 61 + financial_system/repositories/__init__.py | 21 + .../repositories/commission_repository.py | 121 + .../repositories/employee_repository.py | 132 + .../repositories/operation_log_repository.py | 100 + .../repositories/organization_repository.py | 185 ++ .../repositories/payroll_repository.py | 300 +++ .../repositories/report_repository.py | 79 + .../repositories/salary_config_repository.py | 63 + .../repositories/salary_profile_repository.py | 93 + .../repositories/user_repository.py | 96 + financial_system/services/__init__.py | 28 + financial_system/services/auth_service.py | 156 ++ .../services/commission_service.py | 182 ++ financial_system/services/employee_service.py | 183 ++ .../services/operation_log_service.py | 114 + .../services/organization_service.py | 392 +++ financial_system/services/payroll_service.py | 238 ++ financial_system/services/report_service.py | 48 + .../services/salary_config_service.py | 228 ++ .../services/salary_profile_service.py | 117 + frontend/.env.example | 2 + frontend/README.md | 141 + frontend/index.html | 12 + frontend/package-lock.json | 1570 +++++++++++ frontend/package.json | 26 + frontend/src/App.vue | 3 + frontend/src/api/auth.ts | 55 + frontend/src/api/commissions.ts | 53 + frontend/src/api/configs.ts | 24 + frontend/src/api/employees.ts | 48 + frontend/src/api/http.ts | 68 + frontend/src/api/operationLogs.ts | 15 + frontend/src/api/organization.ts | 55 + frontend/src/api/payroll.ts | 98 + frontend/src/api/reports.ts | 11 + frontend/src/api/salaryProfiles.ts | 18 + frontend/src/api/types.ts | 378 +++ frontend/src/assets/styles.css | 2354 +++++++++++++++++ frontend/src/components/AppLayout.vue | 40 + frontend/src/components/AppearancePanel.vue | 316 +++ frontend/src/components/ResultsTable.vue | 113 + frontend/src/components/SidebarNav.vue | 116 + frontend/src/components/StatCard.vue | 24 + frontend/src/components/TopBar.vue | 292 ++ frontend/src/main.ts | 14 + frontend/src/router/index.ts | 173 ++ frontend/src/stores/auth.ts | 102 + frontend/src/stores/theme.ts | 843 ++++++ frontend/src/views/CommissionsView.vue | 360 +++ frontend/src/views/ConfigCenterView.vue | 177 ++ frontend/src/views/DashboardView.vue | 160 ++ frontend/src/views/EmployeesView.vue | 490 ++++ frontend/src/views/JobsView.vue | 176 ++ frontend/src/views/LoginView.vue | 122 + frontend/src/views/OperationLogsView.vue | 290 ++ frontend/src/views/OrganizationView.vue | 399 +++ frontend/src/views/PayrollView.vue | 238 ++ frontend/src/views/ReportsView.vue | 163 ++ frontend/src/views/SalaryProfilesView.vue | 379 +++ frontend/src/views/UsersView.vue | 224 ++ frontend/tsconfig.json | 19 + frontend/tsconfig.node.json | 9 + frontend/vite.config.ts | 27 + main.py | 32 + requirements.txt | 7 + static/swagger-ui/favicon.png | Bin 0 -> 412 bytes static/swagger-ui/swagger-ui-bundle.js | 3 + static/swagger-ui/swagger-ui.css | 4 + 133 files changed, 20680 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/Financial_System.iml create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 README.md create mode 100644 config/app_settings.json create mode 100644 config/salary_rules.example.json create mode 100644 financial_system/__init__.py create mode 100644 financial_system/api/__init__.py create mode 100644 financial_system/api/app.py create mode 100644 financial_system/api/dependencies.py create mode 100644 financial_system/api/request_meta.py create mode 100644 financial_system/api/routers/__init__.py create mode 100644 financial_system/api/routers/auth.py create mode 100644 financial_system/api/routers/commissions.py create mode 100644 financial_system/api/routers/configs.py create mode 100644 financial_system/api/routers/employees.py create mode 100644 financial_system/api/routers/health.py create mode 100644 financial_system/api/routers/operation_logs.py create mode 100644 financial_system/api/routers/organization.py create mode 100644 financial_system/api/routers/payroll.py create mode 100644 financial_system/api/routers/reports.py create mode 100644 financial_system/api/routers/salary_profiles.py create mode 100644 financial_system/api/schemas/__init__.py create mode 100644 financial_system/api/schemas/auth.py create mode 100644 financial_system/api/schemas/commission.py create mode 100644 financial_system/api/schemas/employee.py create mode 100644 financial_system/api/schemas/operation_log.py create mode 100644 financial_system/api/schemas/organization.py create mode 100644 financial_system/api/schemas/payroll.py create mode 100644 financial_system/api/schemas/report.py create mode 100644 financial_system/api/schemas/salary_config.py create mode 100644 financial_system/api/schemas/salary_profile.py create mode 100644 financial_system/cli/__init__.py create mode 100644 financial_system/cli/payroll.py create mode 100644 financial_system/core/__init__.py create mode 100644 financial_system/core/config.py create mode 100644 financial_system/core/logger.py create mode 100644 financial_system/core/permissions.py create mode 100644 financial_system/core/security.py create mode 100644 financial_system/core/settings.py create mode 100644 financial_system/database/__init__.py create mode 100644 financial_system/database/orm.py create mode 100644 financial_system/database/session.py create mode 100644 financial_system/database/sql/init_mysql.sql create mode 100644 financial_system/database/sql/schema.sql create mode 100644 financial_system/database/sql/seed.sql create mode 100644 financial_system/database/sql/upgrade_20260616_employee_salary.sql create mode 100644 financial_system/database/sql/upgrade_20260616_operation_logs.sql create mode 100644 financial_system/database/sql/upgrade_20260616_user_profile.sql create mode 100644 financial_system/database/sql/upgrade_20260617_payroll_modules.sql create mode 100644 financial_system/domain/__init__.py create mode 100644 financial_system/domain/calculator.py create mode 100644 financial_system/domain/models.py create mode 100644 financial_system/integrations/__init__.py create mode 100644 financial_system/integrations/dingtalk.py create mode 100644 financial_system/io/__init__.py create mode 100644 financial_system/io/exporter.py create mode 100644 financial_system/io/parser.py create mode 100644 financial_system/io/storage.py create mode 100644 financial_system/repositories/__init__.py create mode 100644 financial_system/repositories/commission_repository.py create mode 100644 financial_system/repositories/employee_repository.py create mode 100644 financial_system/repositories/operation_log_repository.py create mode 100644 financial_system/repositories/organization_repository.py create mode 100644 financial_system/repositories/payroll_repository.py create mode 100644 financial_system/repositories/report_repository.py create mode 100644 financial_system/repositories/salary_config_repository.py create mode 100644 financial_system/repositories/salary_profile_repository.py create mode 100644 financial_system/repositories/user_repository.py create mode 100644 financial_system/services/__init__.py create mode 100644 financial_system/services/auth_service.py create mode 100644 financial_system/services/commission_service.py create mode 100644 financial_system/services/employee_service.py create mode 100644 financial_system/services/operation_log_service.py create mode 100644 financial_system/services/organization_service.py create mode 100644 financial_system/services/payroll_service.py create mode 100644 financial_system/services/report_service.py create mode 100644 financial_system/services/salary_config_service.py create mode 100644 financial_system/services/salary_profile_service.py create mode 100644 frontend/.env.example create mode 100644 frontend/README.md create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/api/auth.ts create mode 100644 frontend/src/api/commissions.ts create mode 100644 frontend/src/api/configs.ts create mode 100644 frontend/src/api/employees.ts create mode 100644 frontend/src/api/http.ts create mode 100644 frontend/src/api/operationLogs.ts create mode 100644 frontend/src/api/organization.ts create mode 100644 frontend/src/api/payroll.ts create mode 100644 frontend/src/api/reports.ts create mode 100644 frontend/src/api/salaryProfiles.ts create mode 100644 frontend/src/api/types.ts create mode 100644 frontend/src/assets/styles.css create mode 100644 frontend/src/components/AppLayout.vue create mode 100644 frontend/src/components/AppearancePanel.vue create mode 100644 frontend/src/components/ResultsTable.vue create mode 100644 frontend/src/components/SidebarNav.vue create mode 100644 frontend/src/components/StatCard.vue create mode 100644 frontend/src/components/TopBar.vue create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/router/index.ts create mode 100644 frontend/src/stores/auth.ts create mode 100644 frontend/src/stores/theme.ts create mode 100644 frontend/src/views/CommissionsView.vue create mode 100644 frontend/src/views/ConfigCenterView.vue create mode 100644 frontend/src/views/DashboardView.vue create mode 100644 frontend/src/views/EmployeesView.vue create mode 100644 frontend/src/views/JobsView.vue create mode 100644 frontend/src/views/LoginView.vue create mode 100644 frontend/src/views/OperationLogsView.vue create mode 100644 frontend/src/views/OrganizationView.vue create mode 100644 frontend/src/views/PayrollView.vue create mode 100644 frontend/src/views/ReportsView.vue create mode 100644 frontend/src/views/SalaryProfilesView.vue create mode 100644 frontend/src/views/UsersView.vue create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 static/swagger-ui/favicon.png create mode 100644 static/swagger-ui/swagger-ui-bundle.js create mode 100644 static/swagger-ui/swagger-ui.css diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a4a3937 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +APP_CONFIG_FILE=config/app_settings.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fce56f6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +.env +uploads/ +outputs/ +data/ +logs/ +static/uploads/ +frontend/node_modules/ +frontend/dist/ diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..ab1f416 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/Financial_System.iml b/.idea/Financial_System.iml new file mode 100644 index 0000000..d2a06a9 --- /dev/null +++ b/.idea/Financial_System.iml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..8744b55 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,25 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..dc4cec1 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..2c21f11 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e312baa --- /dev/null +++ b/README.md @@ -0,0 +1,316 @@ +# 薪酬考勤管理系统 + +本项目是一套前后端分离的薪酬考勤管理系统,用于处理钉钉月度汇总 Excel、后续钉钉实时接口、员工档案、薪资档案、提成、考勤扣款、加班费、工资汇总和统计报表。 + +核心目标是把人工工资核算链路拆成可维护的数据流: + +```text +钉钉考勤接口 / Excel导入 + -> 数据标准化 + -> 考勤 / 请假 / 加班引擎 + -> 工时统计 + -> 薪资 / 提成 / 奖惩计算 + -> 工资汇总 + -> 工资条 / 月报表 / 财务报表 +``` + +## 当前能力 + +- 支持导入钉钉月度汇总 Excel 计算工资。 +- 支持预留钉钉实时打卡接口计算入口。 +- 自动统计出勤天数、缺勤天数、请假工时、缺卡、迟到扣款。 +- 按下班打卡时间自动计算加班:17:00 下班,18:00 计 1 小时,20:30 计 3 小时。 +- 自动计算:`剩余加班工时 = 打卡推算加班工时 - 请假工时`。 +- 迟到规则:5 分钟内 3 次合格,超过后每次扣 30 元;超过 5 分钟每次扣 30 元。 +- 缺卡规则:每次扣 20 元。 +- 支持组织架构、员工维护、薪资维护、提成管理、规则配置、操作日志、统计报表。 +- 薪资模式支持:包月、计时、计件、试用期。 +- 加班费支持:工作日、周末、法定节假日独立单价。 +- 提成支持手工维护和 Excel 导入,并按工资月份自动汇总进工资计算。 +- 工资计算结果会落库为工资汇总、工资明细、考勤记录、请假记录和加班记录。 + +## 项目结构 + +```text +main.py 后端启动入口 +config/ 后端配置文件 +financial_system/ + api/ FastAPI app、路由、schema、依赖注入 + core/ 配置、权限、安全、日志 + database/ ORM、数据库会话、SQL脚本 + domain/ 领域模型、工资计算器 + integrations/ 钉钉等外部系统适配 + io/ Excel解析、导出、文件存储 + repositories/ 数据库读写层 + services/ 业务服务层 +frontend/ Vue3 前端项目 +``` + +分层约定: + +- `routers` 只处理 HTTP、权限、参数和操作日志。 +- `schemas` 只定义请求响应结构。 +- `services` 只做业务编排和校验。 +- `repositories` 只负责 ORM 读写。 +- `domain` 只负责纯计算规则,不依赖数据库和 HTTP。 + +## 运行环境 + +后端使用 Conda 环境: + +```bash +/Users/jiaolongyan/miniconda3/envs/Financial_System/bin/python main.py +``` + +默认访问: + +- 后端接口文档:http://127.0.0.1:8000/docs +- 健康检查:http://127.0.0.1:8000/health +- 前端开发地址:http://127.0.0.1:5173 + +前端启动: + +```bash +cd frontend +npm install +npm run dev +``` + +前端构建: + +```bash +cd frontend +npm run build +``` + +## 统一配置 + +后端配置统一放在: + +```text +config/app_settings.json +``` + +常用配置: + +| 配置 | 说明 | +| --- | --- | +| `server.host` / `server.port` | 后端监听地址和端口 | +| `database.url` | 数据库连接 | +| `dingtalk.app_key` / `dingtalk.app_secret` | 钉钉开放平台凭证 | +| `storage.upload_dir` | 上传文件目录 | +| `storage.output_dir` | 导出工资文件目录 | +| `storage.avatar_dir` | 头像上传目录 | +| `logging.*` | info/error 日志文件和滚动策略 | +| `auth.secret_key` | Token 签名密钥 | +| `bootstrap_superuser.*` | 首次启动超级管理员 | +| `cors.allowed_origins` | 前端跨域来源 | + +默认 MySQL: + +```json +{ + "database": { + "url": "mysql+pymysql://root:12345678@localhost:3306/financial_system?charset=utf8mb4" + } +} +``` + +如需使用其他配置文件: + +```bash +APP_CONFIG_FILE=/path/to/app_settings.json /Users/jiaolongyan/miniconda3/envs/Financial_System/bin/python main.py +``` + +## 数据库 + +本地 MySQL: + +```text +host: localhost +port: 3306 +user: root +password: 12345678 +database: financial_system +``` + +完整初始化: + +```bash +mysql -h localhost -P 3306 -u root -p12345678 < financial_system/database/sql/init_mysql.sql +``` + +已有旧库升级到当前版本: + +```bash +mysql -h localhost -P 3306 -u root -p12345678 financial_system < financial_system/database/sql/upgrade_20260617_payroll_modules.sql +``` + +SQL 文件说明: + +| 文件 | 说明 | +| --- | --- | +| `schema.sql` | 建库建表脚本,包含所有表和字段注释 | +| `seed.sql` | 初始化默认超级管理员 | +| `init_mysql.sql` | 一键初始化脚本,包含建库、建表、默认配置、默认管理员 | +| `upgrade_20260617_payroll_modules.sql` | 当前薪酬考勤完整模块升级脚本 | + +主要业务表: + +| 表 | 说明 | +| --- | --- | +| `users` | 系统用户 | +| `departments` | 部门及上下级关系 | +| `positions` | 部门下的岗位 | +| `employees` | 员工档案 | +| `salary_profiles` | 员工薪资档案 | +| `attendance_record` | 标准化考勤记录 | +| `leave_record` | 请假/调休记录 | +| `overtime_record` | 加班记录 | +| `commission_record` | 提成/奖金记录 | +| `payroll_jobs` | 工资计算任务 | +| `payroll_results` | 员工工资计算结果 | +| `salary_record` | 月度工资汇总 | +| `salary_detail` | 工资明细项 | +| `salary_config` | 规则配置中心 | +| `operation_logs` | 操作日志 | + +服务启动时会执行 ORM 建表检查,并补齐默认超级管理员和规则配置。 + +组织架构启动时会按内置层级补齐部门和岗位: + +- 部门顺序按总经办、行政人事部、财务部、订单部、工程部、杭州运营中心、华南/华中/西南运营中心、生产中心等层级维护。 +- `工程部-安装队`、`工程部-项目预算` 会挂到自动补齐的 `工程部` 父级下。 +- `杭州运营中心-销售部` 会挂到自动补齐的 `杭州运营中心` 父级下。 +- `生产中心-采购部/仓库/品管部/生产部` 会挂到 `生产中心` 下。 +- `西南运营中心-办事处/销售部` 会挂到 `西南运营中心` 下。 +- 岗位编码由系统自动生成,页面只展示编码,不需要人工维护。 + +员工维护中,员工编号同样由系统自动生成,默认格式为 `ZA0001`、`ZA0002` 递增,前端只展示不手工维护。 + +## 规则配置 + +规则配置既可以通过前端“规则配置”页面维护,也可以参考: + +```text +config/salary_rules.example.json +``` + +默认关键规则: + +- 上班时间:08:30 +- 下班时间:17:00 +- 加班取整:60 分钟向下取整 +- 标准日工时:8 小时 +- 5 分钟内迟到免扣次数:3 次 +- 迟到扣款:30 元/次 +- 缺卡扣款:20 元/次 +- 周末定义:周六、周日 +- 法定节假日:可在规则配置中维护日期数组 + +工资公式: + +```text +应发工资 = 基础工资 + 提成 + 加班费 - 迟到扣款 - 缺卡扣款 - 其他扣款 +``` + +基础工资按薪资模式计算: + +- 包月:固定底薪 +- 计时:实际工作工时 × 小时单价 +- 计件:计件数量 × 计件单价 +- 试用期:底薪 × 固定比例,或固定金额 + +## 前端页面 + +| 页面 | 路由 | 说明 | +| --- | --- | --- | +| 登录 | `/login` | 用户登录 | +| 工作台 | `/dashboard` | 系统概览 | +| 工资计算 | `/payroll` | Excel 导入和钉钉实时计算 | +| 计算记录 | `/payroll/jobs` | 查看历史计算任务 | +| 提成管理 | `/payroll/commissions` | 手工维护和 Excel 导入提成 | +| 统计报表 | `/reports` | 工资汇总、部门汇总、考勤工时 | +| 组织架构 | `/system/organization` | 维护部门上下级和部门岗位 | +| 员工维护 | `/system/employees` | 员工分页维护 | +| 薪资维护 | `/system/salary-profiles` | 员工薪资模式和加班单价 | +| 规则配置 | `/system/configs` | 考勤和薪资规则 | +| 用户管理 | `/system/users` | 超级用户创建账号 | +| 操作日志 | `/system/operation-logs` | 查看系统操作审计 | + +前端支持主题色切换、侧边栏收起、个人资料修改、头像上传、修改密码。 + +## 登录与权限 + +首次启动默认超级管理员: + +```text +用户名:admin +密码:Admin@123456 +角色:superuser +``` + +角色: + +| 角色 | 说明 | +| --- | --- | +| `superuser` | 超级用户,拥有全部菜单和操作权限 | +| `manager` | 管理者,可维护组织架构、员工、薪资、提成、规则、报表和工资计算 | +| `viewer` | 查看用户,可查看计算记录、下载结果和统计报表 | + +登录示例: + +```bash +curl -X POST "http://127.0.0.1:8000/api/auth/login" \ + -H "Content-Type: application/json" \ + -d '{"username":"admin","password":"Admin@123456"}' +``` + +## 常用接口 + +上传 Excel 计算工资: + +```bash +curl -X POST "http://127.0.0.1:8000/api/payroll/excel" \ + -H "Authorization: Bearer " \ + -F "file=@/path/to/月度汇总.xlsx" \ + -F "export_excel=true" +``` + +钉钉实时计算: + +```bash +curl -X POST "http://127.0.0.1:8000/api/payroll/dingtalk/realtime" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"user_ids":["001","002"],"start_date":"2026-05-01","end_date":"2026-05-31","export_excel":true}' +``` + +新增提成: + +```bash +curl -X POST "http://127.0.0.1:8000/api/commissions" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"employee_id":1,"commission_month":"2026-06","commission_type":"销售提成","amount":1000,"source_type":"manual","business_ref":"","remark":""}' +``` + +查询统计报表: + +```bash +curl "http://127.0.0.1:8000/api/reports/payroll-summary?salary_month=2026-06" \ + -H "Authorization: Bearer " +``` + +## 日志 + +日志统一由 `financial_system/core/logger.py` 封装。 + +默认文件: + +| 文件 | 说明 | +| --- | --- | +| `logs/info.log` | 正常流程日志 | +| `logs/error.log` | 错误和异常日志 | + +修改 `config/app_settings.json` 中 `logging.*` 后重启服务即可生效。 diff --git a/config/app_settings.json b/config/app_settings.json new file mode 100644 index 0000000..ba1d724 --- /dev/null +++ b/config/app_settings.json @@ -0,0 +1,47 @@ +{ + "server": { + "host": "0.0.0.0", + "port": 8000, + "fallback_port": 8001 + }, + "logging": { + "dir": "logs", + "level": "INFO", + "info_file": "info.log", + "error_file": "error.log", + "max_bytes": 10485760, + "backup_count": 10, + "console_enabled": true + }, + "database": { + "url": "mysql+pymysql://root:12345678@localhost:3306/financial_system?charset=utf8mb4" + }, + "storage": { + "upload_dir": "uploads", + "output_dir": "outputs", + "avatar_dir": "static/uploads/avatars" + }, + "dingtalk": { + "app_key": "", + "app_secret": "", + "base_url": "https://oapi.dingtalk.com", + "timezone": "Asia/Shanghai" + }, + "auth": { + "secret_key": "financial-system-dev-secret", + "access_token_expire_minutes": 480 + }, + "bootstrap_superuser": { + "username": "admin", + "password": "Admin@123456", + "display_name": "超级管理员" + }, + "cors": { + "allowed_origins": [ + "http://127.0.0.1:5173", + "http://localhost:5173", + "http://127.0.0.1:5174", + "http://localhost:5174" + ] + } +} diff --git a/config/salary_rules.example.json b/config/salary_rules.example.json new file mode 100644 index 0000000..1538126 --- /dev/null +++ b/config/salary_rules.example.json @@ -0,0 +1,36 @@ +{ + "attendance": { + "on_work_time": "08:30", + "off_work_time": "17:00", + "overtime_round_minutes": 60, + "standard_daily_hours": 8, + "minor_late_minutes": 5, + "minor_late_free_times": 3, + "late_penalty": 30, + "missing_card_penalty": 20, + "weekend_days": [5, 6], + "legal_holidays": ["2026-10-01", "2026-10-02"], + "leave_keywords": ["调休", "请假", "事假", "病假", "年假", "婚假", "产假", "陪产假", "丧假"] + }, + "payroll": { + "default_overtime_rate": 0, + "default_weekend_overtime_rate": 0, + "default_holiday_overtime_rate": 0, + "employees": { + "张三": { + "salary_mode": "monthly", + "base_salary": 6000, + "hourly_rate": 0, + "piece_quantity": 0, + "piece_unit_price": 0, + "probation_type": "ratio", + "probation_ratio": 1, + "probation_salary": null, + "commission_amount": 500, + "overtime_rate": 25, + "weekend_overtime_rate": 35, + "holiday_overtime_rate": 50 + } + } + } +} diff --git a/financial_system/__init__.py b/financial_system/__init__.py new file mode 100644 index 0000000..b0a065c --- /dev/null +++ b/financial_system/__init__.py @@ -0,0 +1,5 @@ +"""Attendance-driven payroll calculator for monthly DingTalk-style summaries.""" + +__all__ = ["__version__"] + +__version__ = "0.1.0" diff --git a/financial_system/api/__init__.py b/financial_system/api/__init__.py new file mode 100644 index 0000000..9196e97 --- /dev/null +++ b/financial_system/api/__init__.py @@ -0,0 +1,3 @@ +from .app import app, create_app + +__all__ = ["app", "create_app"] diff --git a/financial_system/api/app.py b/financial_system/api/app.py new file mode 100644 index 0000000..f551d75 --- /dev/null +++ b/financial_system/api/app.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request +from fastapi.openapi.docs import get_swagger_ui_html +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from fastapi.staticfiles import StaticFiles + +from ..core.logger import AppLogger +from ..core.settings import PROJECT_ROOT, get_settings +from ..database import SessionLocal, init_database +from ..repositories import OrganizationRepository, SalaryConfigRepository, UserRepository +from ..services import AuthService, OrganizationService, SalaryConfigService +from .routers import auth, commissions, configs, employees, health, operation_logs, organization, payroll, reports, salary_profiles + +logger = AppLogger.get_logger(__name__) + + +@asynccontextmanager +async def lifespan(_: FastAPI): + """应用启动时建表并保证至少有一个超级用户。""" + logger.info("应用启动初始化开始") + try: + init_database() + _bootstrap_superuser() + _bootstrap_salary_configs() + _bootstrap_departments() + except Exception: + logger.exception("应用启动初始化失败") + raise + logger.info("应用启动初始化完成") + yield + logger.info("应用关闭") + + +def create_app() -> FastAPI: + """集中创建 FastAPI 应用,路由注册和启动生命周期都在这里完成。""" + settings = get_settings() + AppLogger.configure(settings) + logger.info("创建 FastAPI 应用 config_path=%s", settings.config_path) + app = FastAPI( + title="Financial System Payroll API", + description="支持导入月度汇总 Excel,也支持对接钉钉实时打卡记录进行工资计算。", + version="0.1.0", + docs_url=None, + lifespan=lifespan, + ) + _register_cors(app, settings.cors_allowed_origins) + _mount_local_swagger_assets(app) + _register_docs_route(app) + _register_exception_handlers(app) + app.include_router(auth.router) + app.include_router(commissions.router) + app.include_router(configs.router) + app.include_router(employees.router) + app.include_router(health.router) + app.include_router(operation_logs.router) + app.include_router(organization.router) + app.include_router(payroll.router) + app.include_router(reports.router) + app.include_router(salary_profiles.router) + return app + + +def _register_cors(app: FastAPI, allowed_origins: tuple[str, ...]) -> None: + """允许前端独立端口访问 API,生产环境可通过环境变量收窄来源。""" + app.add_middleware( + CORSMiddleware, + allow_origins=list(allowed_origins), + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + +def _register_exception_handlers(app: FastAPI) -> None: + @app.exception_handler(Exception) + async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse: + logger.exception("未处理异常 method=%s path=%s", request.method, request.url.path) + return JSONResponse(status_code=500, content={"detail": "服务器内部错误,请查看错误日志"}) + + +def _mount_local_swagger_assets(app: FastAPI) -> None: + """挂载本地 Swagger 静态文件,避免依赖外部 CDN。""" + static_dir = PROJECT_ROOT / "static" + if static_dir.exists(): + app.mount("/static", StaticFiles(directory=static_dir), name="static") + logger.info("已挂载本地 Swagger 静态资源 static_dir=%s", static_dir) + + +def _register_docs_route(app: FastAPI) -> None: + @app.get("/docs", include_in_schema=False) + def custom_swagger_ui_html(): + return get_swagger_ui_html( + openapi_url=app.openapi_url, + title=f"{app.title} - Swagger UI", + swagger_js_url="/static/swagger-ui/swagger-ui-bundle.js", + swagger_css_url="/static/swagger-ui/swagger-ui.css", + swagger_favicon_url="/static/swagger-ui/favicon.png", + ) + + +def _bootstrap_superuser() -> None: + """首次运行时初始化超级用户;已有用户时不覆盖数据库。""" + session = SessionLocal() + try: + service = AuthService(UserRepository(session), get_settings()) + service.ensure_bootstrap_superuser() + logger.info("超级管理员初始化检查完成") + finally: + session.close() + + +def _bootstrap_salary_configs() -> None: + """首次运行时补齐规则配置中心默认项。""" + session = SessionLocal() + try: + service = SalaryConfigService(SalaryConfigRepository(session)) + service.ensure_defaults() + logger.info("系统规则配置初始化检查完成") + finally: + session.close() + + +def _bootstrap_departments() -> None: + """首次运行时补齐组织架构默认部门和岗位。""" + session = SessionLocal() + try: + service = OrganizationService(OrganizationRepository(session)) + service.ensure_default_departments() + logger.info("组织架构默认部门和岗位初始化检查完成") + finally: + session.close() + + +app = create_app() diff --git a/financial_system/api/dependencies.py b/financial_system/api/dependencies.py new file mode 100644 index 0000000..fc86444 --- /dev/null +++ b/financial_system/api/dependencies.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +from collections.abc import Generator +from typing import Callable + +from fastapi import Depends, HTTPException +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy.orm import Session + +from ..core.logger import AppLogger +from ..core.permissions import has_permission +from ..core.security import TokenError, decode_access_token +from ..core.settings import AppSettings, get_settings +from ..database import SessionLocal +from ..database.orm import UserORM +from ..repositories import ( + CommissionRepository, + EmployeeRepository, + OperationLogRepository, + OrganizationRepository, + PayrollRepository, + ReportRepository, + SalaryConfigRepository, + SalaryProfileRepository, + UserRepository, +) +from ..services import ( + AuthService, + CommissionService, + EmployeeService, + OperationLogService, + OrganizationService, + PayrollApplicationService, + ReportService, + SalaryConfigService, + SalaryProfileService, +) +from ..io.storage import FileStorage + +bearer_scheme = HTTPBearer(auto_error=False) +logger = AppLogger.get_logger(__name__) + + +def get_db_session() -> Generator[Session, None, None]: + """为单次 HTTP 请求提供数据库会话,并在请求结束后关闭。""" + session = SessionLocal() + try: + yield session + finally: + session.close() + + +def get_app_settings() -> AppSettings: + return get_settings() + + +def get_payroll_repository(session: Session = Depends(get_db_session)) -> PayrollRepository: + return PayrollRepository(session) + + +def get_commission_repository(session: Session = Depends(get_db_session)) -> CommissionRepository: + return CommissionRepository(session) + + +def get_employee_repository(session: Session = Depends(get_db_session)) -> EmployeeRepository: + return EmployeeRepository(session) + + +def get_report_repository(session: Session = Depends(get_db_session)) -> ReportRepository: + return ReportRepository(session) + + +def get_salary_config_repository(session: Session = Depends(get_db_session)) -> SalaryConfigRepository: + return SalaryConfigRepository(session) + + +def get_salary_profile_repository(session: Session = Depends(get_db_session)) -> SalaryProfileRepository: + return SalaryProfileRepository(session) + + +def get_user_repository(session: Session = Depends(get_db_session)) -> UserRepository: + return UserRepository(session) + + +def get_operation_log_repository(session: Session = Depends(get_db_session)) -> OperationLogRepository: + return OperationLogRepository(session) + + +def get_organization_repository(session: Session = Depends(get_db_session)) -> OrganizationRepository: + return OrganizationRepository(session) + + +def get_auth_service( + repository: UserRepository = Depends(get_user_repository), + settings: AppSettings = Depends(get_app_settings), +) -> AuthService: + return AuthService(repository=repository, settings=settings) + + +def get_operation_log_service( + repository: OperationLogRepository = Depends(get_operation_log_repository), +) -> OperationLogService: + return OperationLogService(repository=repository) + + +def get_organization_service( + repository: OrganizationRepository = Depends(get_organization_repository), +) -> OrganizationService: + return OrganizationService(repository=repository) + + +def get_employee_service( + repository: EmployeeRepository = Depends(get_employee_repository), +) -> EmployeeService: + return EmployeeService(repository=repository) + + +def get_salary_profile_service( + repository: SalaryProfileRepository = Depends(get_salary_profile_repository), + employee_repository: EmployeeRepository = Depends(get_employee_repository), +) -> SalaryProfileService: + return SalaryProfileService(repository=repository, employee_repository=employee_repository) + + +def get_commission_service( + repository: CommissionRepository = Depends(get_commission_repository), + employee_repository: EmployeeRepository = Depends(get_employee_repository), + settings: AppSettings = Depends(get_app_settings), +) -> CommissionService: + return CommissionService( + repository=repository, + employee_repository=employee_repository, + storage=FileStorage(settings), + ) + + +def get_salary_config_service( + repository: SalaryConfigRepository = Depends(get_salary_config_repository), +) -> SalaryConfigService: + return SalaryConfigService(repository=repository) + + +def get_report_service( + repository: ReportRepository = Depends(get_report_repository), +) -> ReportService: + return ReportService(repository=repository) + + +def get_payroll_service( + repository: PayrollRepository = Depends(get_payroll_repository), + salary_repository: SalaryProfileRepository = Depends(get_salary_profile_repository), + commission_repository: CommissionRepository = Depends(get_commission_repository), + config_repository: SalaryConfigRepository = Depends(get_salary_config_repository), + settings: AppSettings = Depends(get_app_settings), +) -> PayrollApplicationService: + return PayrollApplicationService( + repository=repository, + settings=settings, + salary_repository=salary_repository, + commission_repository=commission_repository, + config_service=SalaryConfigService(config_repository), + ) + + +def get_current_user( + credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), + repository: UserRepository = Depends(get_user_repository), + settings: AppSettings = Depends(get_app_settings), +) -> UserORM: + """解析 Bearer token,并返回仍然有效的当前用户。""" + if credentials is None: + logger.error("认证失败 reason=missing_bearer_token") + raise HTTPException(status_code=401, detail="请先登录") + try: + payload = decode_access_token(credentials.credentials, settings.auth_secret_key) + user_id = int(payload["sub"]) + except (KeyError, TypeError, ValueError, TokenError) as exc: + logger.error("认证失败 reason=invalid_or_expired_token") + raise HTTPException(status_code=401, detail="登录状态无效或已过期") from exc + user = repository.get_by_id(user_id) + if user is None or not user.is_active: + logger.error("认证失败 user_id=%s reason=user_not_found_or_inactive", user_id) + raise HTTPException(status_code=401, detail="用户不存在或已停用") + logger.info("认证成功 user_id=%s username=%s role=%s", user.id, user.username, user.role) + return user + + +def require_permission(permission: str) -> Callable: + """生成路由级权限依赖,让路由只声明所需权限。""" + def dependency(user: UserORM = Depends(get_current_user)) -> UserORM: + if not has_permission(user.role, permission): + logger.error( + "权限校验失败 user_id=%s username=%s role=%s permission=%s", + user.id, + user.username, + user.role, + permission, + ) + raise HTTPException(status_code=403, detail="没有该操作权限") + logger.info( + "权限校验通过 user_id=%s username=%s role=%s permission=%s", + user.id, + user.username, + user.role, + permission, + ) + return user + + return dependency diff --git a/financial_system/api/request_meta.py b/financial_system/api/request_meta.py new file mode 100644 index 0000000..6c848cc --- /dev/null +++ b/financial_system/api/request_meta.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from fastapi import Request + + +def request_client_ip(request: Request) -> str: + """提取客户端 IP,优先读取代理转发头。""" + forwarded_for = request.headers.get("x-forwarded-for", "") + if forwarded_for: + return forwarded_for.split(",", 1)[0].strip() + return request.client.host if request.client else "" + + +def request_user_agent(request: Request) -> str: + """提取浏览器 User-Agent。""" + return request.headers.get("user-agent", "") + + +def operation_request_meta(request: Request) -> dict[str, str]: + """生成操作日志使用的请求元信息。""" + return { + "ip_address": request_client_ip(request), + "user_agent": request_user_agent(request), + } diff --git a/financial_system/api/routers/__init__.py b/financial_system/api/routers/__init__.py new file mode 100644 index 0000000..88a3441 --- /dev/null +++ b/financial_system/api/routers/__init__.py @@ -0,0 +1 @@ +__all__ = ["auth", "employees", "health", "operation_logs", "payroll", "salary_profiles"] diff --git a/financial_system/api/routers/auth.py b/financial_system/api/routers/auth.py new file mode 100644 index 0000000..dcbbd54 --- /dev/null +++ b/financial_system/api/routers/auth.py @@ -0,0 +1,266 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile + +from ...core.logger import AppLogger +from ...core.permissions import PERMISSION_USER_CREATE, PERMISSION_USER_LIST +from ...database.orm import UserORM +from ...services import AuthService, OperationLogService +from ..dependencies import get_auth_service, get_current_user, get_operation_log_service, require_permission +from ..request_meta import operation_request_meta +from ..schemas.auth import ( + ChangePasswordRequest, + ChangePasswordResponse, + CreateUserRequest, + LoginRequest, + LoginResponse, + UpdateProfileRequest, + UserProfileDTO, + UserResponse, +) + +router = APIRouter(prefix="/api/auth", tags=["auth"]) +logger = AppLogger.get_logger(__name__) + + +@router.post("/login", response_model=LoginResponse) +def login( + http_request: Request, + request: LoginRequest, + service: AuthService = Depends(get_auth_service), + operation_logs: OperationLogService = Depends(get_operation_log_service), +) -> LoginResponse: + """登录成功后返回 token、可见菜单和可执行操作权限。""" + try: + session = service.login(request.username, request.password) + except ValueError as exc: + operation_logs.record( + actor=None, + module="auth", + action="login", + target_type="user", + target_id=request.username, + status="failed", + detail=str(exc), + **operation_request_meta(http_request), + ) + logger.error("登录接口失败 username=%s reason=%s", request.username, exc) + raise HTTPException(status_code=401, detail=str(exc)) from exc + operation_logs.record( + actor=session.user, + module="auth", + action="login", + target_type="user", + target_id=str(session.user.id), + status="success", + detail="用户登录成功", + **operation_request_meta(http_request), + ) + return LoginResponse.from_session(session) + + +@router.get("/me", response_model=UserProfileDTO) +def me(current_user: UserORM = Depends(get_current_user)) -> UserProfileDTO: + """前端刷新页面时用它恢复当前用户权限上下文。""" + return UserProfileDTO.from_orm_model(current_user) + + +@router.put("/me", response_model=UserProfileDTO) +def update_me( + http_request: Request, + request: UpdateProfileRequest, + current_user: UserORM = Depends(get_current_user), + service: AuthService = Depends(get_auth_service), + operation_logs: OperationLogService = Depends(get_operation_log_service), +) -> UserProfileDTO: + """当前登录用户修改个人基础信息。""" + try: + user = service.update_profile( + current_user.id, + display_name=request.display_name, + email=request.email, + phone=request.phone, + department=request.department, + position_title=request.position_title, + ) + except ValueError as exc: + operation_logs.record( + actor=current_user, + module="auth", + action="profile.update", + target_type="user", + target_id=str(current_user.id), + status="failed", + detail=str(exc), + **operation_request_meta(http_request), + ) + logger.error("修改个人资料接口失败 user_id=%s reason=%s", current_user.id, exc) + raise HTTPException(status_code=400, detail=str(exc)) from exc + operation_logs.record( + actor=user, + module="auth", + action="profile.update", + target_type="user", + target_id=str(user.id), + status="success", + detail="修改个人资料成功", + **operation_request_meta(http_request), + ) + return UserProfileDTO.from_orm_model(user) + + +@router.put("/me/password", response_model=ChangePasswordResponse) +def change_my_password( + http_request: Request, + request: ChangePasswordRequest, + current_user: UserORM = Depends(get_current_user), + service: AuthService = Depends(get_auth_service), + operation_logs: OperationLogService = Depends(get_operation_log_service), +) -> ChangePasswordResponse: + """当前登录用户修改自己的登录密码。""" + try: + service.change_password( + current_user.id, + old_password=request.old_password, + new_password=request.new_password, + confirm_password=request.confirm_password, + ) + except ValueError as exc: + operation_logs.record( + actor=current_user, + module="auth", + action="password.change", + target_type="user", + target_id=str(current_user.id), + status="failed", + detail=str(exc), + **operation_request_meta(http_request), + ) + logger.error("修改密码接口失败 user_id=%s reason=%s", current_user.id, exc) + raise HTTPException(status_code=400, detail=str(exc)) from exc + operation_logs.record( + actor=current_user, + module="auth", + action="password.change", + target_type="user", + target_id=str(current_user.id), + status="success", + detail="修改密码成功", + **operation_request_meta(http_request), + ) + return ChangePasswordResponse(message="密码修改成功") + + +@router.post("/me/avatar", response_model=UserProfileDTO) +def update_my_avatar( + http_request: Request, + file: UploadFile = File(...), + current_user: UserORM = Depends(get_current_user), + service: AuthService = Depends(get_auth_service), + operation_logs: OperationLogService = Depends(get_operation_log_service), +) -> UserProfileDTO: + """当前登录用户上传并修改头像。""" + try: + user = service.update_avatar( + current_user.id, + filename=file.filename or "avatar.png", + stream=file.file, + ) + except ValueError as exc: + operation_logs.record( + actor=current_user, + module="auth", + action="avatar.update", + target_type="user", + target_id=str(current_user.id), + status="failed", + detail=str(exc), + **operation_request_meta(http_request), + ) + logger.error("修改头像接口失败 user_id=%s filename=%s reason=%s", current_user.id, file.filename, exc) + raise HTTPException(status_code=400, detail=str(exc)) from exc + operation_logs.record( + actor=user, + module="auth", + action="avatar.update", + target_type="user", + target_id=str(user.id), + status="success", + detail=f"修改头像成功 filename={file.filename or 'avatar.png'}", + **operation_request_meta(http_request), + ) + return UserProfileDTO.from_orm_model(user) + + +@router.post( + "/users", + response_model=UserResponse, +) +def create_user( + http_request: Request, + request: CreateUserRequest, + current_user: UserORM = Depends(require_permission(PERMISSION_USER_CREATE)), + service: AuthService = Depends(get_auth_service), + operation_logs: OperationLogService = Depends(get_operation_log_service), +) -> UserResponse: + """只有超级用户可以创建用户,角色决定菜单和操作权限。""" + try: + user = service.create_user( + username=request.username, + password=request.password, + role=request.role, + display_name=request.display_name, + email=request.email, + phone=request.phone, + department=request.department, + position_title=request.position_title, + ) + except ValueError as exc: + operation_logs.record( + actor=current_user, + module="system", + action="user.create", + target_type="user", + target_id=request.username, + status="failed", + detail=str(exc), + **operation_request_meta(http_request), + ) + logger.error("创建用户接口失败 username=%s role=%s reason=%s", request.username, request.role, exc) + raise HTTPException(status_code=400, detail=str(exc)) from exc + operation_logs.record( + actor=current_user, + module="system", + action="user.create", + target_type="user", + target_id=str(user.id), + status="success", + detail=f"创建用户成功 username={user.username} role={user.role}", + **operation_request_meta(http_request), + ) + return UserResponse.from_orm_model(user) + + +@router.get( + "/users", + response_model=list[UserResponse], +) +def list_users( + http_request: Request, + current_user: UserORM = Depends(require_permission(PERMISSION_USER_LIST)), + service: AuthService = Depends(get_auth_service), + operation_logs: OperationLogService = Depends(get_operation_log_service), +) -> list[UserResponse]: + """用户管理菜单使用的用户列表。""" + users = service.list_users() + operation_logs.record( + actor=current_user, + module="system", + action="user.list", + target_type="user", + target_id="", + status="success", + detail=f"查询用户列表 count={len(users)}", + **operation_request_meta(http_request), + ) + return [UserResponse.from_orm_model(user) for user in users] diff --git a/financial_system/api/routers/commissions.py b/financial_system/api/routers/commissions.py new file mode 100644 index 0000000..2c2f656 --- /dev/null +++ b/financial_system/api/routers/commissions.py @@ -0,0 +1,191 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile + +from ...core.logger import AppLogger +from ...core.permissions import PERMISSION_COMMISSION_MANAGE, PERMISSION_COMMISSION_VIEW +from ...database.orm import UserORM +from ...services import CommissionService, OperationLogService +from ..dependencies import get_commission_service, get_operation_log_service, require_permission +from ..request_meta import operation_request_meta +from ..schemas.commission import ( + CommissionImportResponse, + CommissionListResponse, + CommissionRecordRequest, + CommissionRecordResponse, +) + +router = APIRouter(prefix="/api/commissions", tags=["commissions"]) +logger = AppLogger.get_logger(__name__) + + +@router.get("", response_model=CommissionListResponse) +def list_commissions( + http_request: Request, + page: int = Query(default=1, ge=1), + page_size: int = Query(default=10, ge=1, le=100), + commission_month: str | None = Query(default=None, description="提成月份YYYY-MM"), + keyword: str | None = Query(default=None, description="员工、类型或业务单号关键字"), + current_user: UserORM = Depends(require_permission(PERMISSION_COMMISSION_VIEW)), + service: CommissionService = Depends(get_commission_service), + operation_logs: OperationLogService = Depends(get_operation_log_service), +) -> CommissionListResponse: + """分页查询提成记录。""" + try: + page_data = service.list_records( + page=page, + page_size=page_size, + commission_month=commission_month, + keyword=keyword, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + operation_logs.record( + actor=current_user, + module="payroll", + action="commission.list", + target_type="commission_record", + target_id="", + status="success", + detail=f"查询提成记录 total={page_data.total} page={page}", + **operation_request_meta(http_request), + ) + return CommissionListResponse.from_page(page_data) + + +@router.post("", response_model=CommissionRecordResponse) +def create_commission( + http_request: Request, + request: CommissionRecordRequest, + current_user: UserORM = Depends(require_permission(PERMISSION_COMMISSION_MANAGE)), + service: CommissionService = Depends(get_commission_service), + operation_logs: OperationLogService = Depends(get_operation_log_service), +) -> CommissionRecordResponse: + """新增提成记录。""" + try: + record = service.create_record(**request.model_dump()) + except ValueError as exc: + operation_logs.record( + actor=current_user, + module="payroll", + action="commission.create", + target_type="commission_record", + target_id=str(request.employee_id), + status="failed", + detail=str(exc), + **operation_request_meta(http_request), + ) + logger.error("新增提成记录失败 employee_id=%s reason=%s", request.employee_id, exc) + raise HTTPException(status_code=400, detail=str(exc)) from exc + operation_logs.record( + actor=current_user, + module="payroll", + action="commission.create", + target_type="commission_record", + target_id=str(record.id), + status="success", + detail=f"新增提成记录 amount={record.amount} month={record.commission_month}", + **operation_request_meta(http_request), + ) + return CommissionRecordResponse.from_orm_model(record) + + +@router.put("/{record_id}", response_model=CommissionRecordResponse) +def update_commission( + http_request: Request, + record_id: int, + request: CommissionRecordRequest, + current_user: UserORM = Depends(require_permission(PERMISSION_COMMISSION_MANAGE)), + service: CommissionService = Depends(get_commission_service), + operation_logs: OperationLogService = Depends(get_operation_log_service), +) -> CommissionRecordResponse: + """更新提成记录。""" + try: + record = service.update_record(record_id, **request.model_dump()) + except ValueError as exc: + operation_logs.record( + actor=current_user, + module="payroll", + action="commission.update", + target_type="commission_record", + target_id=str(record_id), + status="failed", + detail=str(exc), + **operation_request_meta(http_request), + ) + logger.error("更新提成记录失败 record_id=%s reason=%s", record_id, exc) + raise HTTPException(status_code=400, detail=str(exc)) from exc + operation_logs.record( + actor=current_user, + module="payroll", + action="commission.update", + target_type="commission_record", + target_id=str(record.id), + status="success", + detail=f"更新提成记录 amount={record.amount} month={record.commission_month}", + **operation_request_meta(http_request), + ) + return CommissionRecordResponse.from_orm_model(record) + + +@router.delete("/{record_id}") +def delete_commission( + http_request: Request, + record_id: int, + current_user: UserORM = Depends(require_permission(PERMISSION_COMMISSION_MANAGE)), + service: CommissionService = Depends(get_commission_service), + operation_logs: OperationLogService = Depends(get_operation_log_service), +) -> dict[str, str]: + """删除提成记录。""" + try: + service.delete_record(record_id) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + operation_logs.record( + actor=current_user, + module="payroll", + action="commission.delete", + target_type="commission_record", + target_id=str(record_id), + status="success", + detail="删除提成记录成功", + **operation_request_meta(http_request), + ) + return {"message": "删除成功"} + + +@router.post("/import", response_model=CommissionImportResponse) +def import_commissions( + http_request: Request, + file: UploadFile = File(...), + current_user: UserORM = Depends(require_permission(PERMISSION_COMMISSION_MANAGE)), + service: CommissionService = Depends(get_commission_service), + operation_logs: OperationLogService = Depends(get_operation_log_service), +) -> CommissionImportResponse: + """导入提成 Excel。""" + try: + count = service.import_excel(file.filename or "commission.xlsx", file.file) + except ValueError as exc: + operation_logs.record( + actor=current_user, + module="payroll", + action="commission.import", + target_type="file", + target_id=file.filename or "", + status="failed", + detail=str(exc), + **operation_request_meta(http_request), + ) + logger.error("导入提成Excel失败 filename=%s reason=%s", file.filename, exc) + raise HTTPException(status_code=400, detail=str(exc)) from exc + operation_logs.record( + actor=current_user, + module="payroll", + action="commission.import", + target_type="file", + target_id=file.filename or "", + status="success", + detail=f"导入提成Excel成功 count={count}", + **operation_request_meta(http_request), + ) + return CommissionImportResponse(imported_count=count, message="导入成功") diff --git a/financial_system/api/routers/configs.py b/financial_system/api/routers/configs.py new file mode 100644 index 0000000..7df17cd --- /dev/null +++ b/financial_system/api/routers/configs.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query, Request + +from ...core.logger import AppLogger +from ...core.permissions import PERMISSION_CONFIG_MANAGE, PERMISSION_CONFIG_VIEW +from ...database.orm import UserORM +from ...services import OperationLogService, SalaryConfigService +from ..dependencies import get_operation_log_service, get_salary_config_service, require_permission +from ..request_meta import operation_request_meta +from ..schemas.salary_config import ( + SalaryConfigDefaultsResponse, + SalaryConfigRequest, + SalaryConfigResponse, +) + +router = APIRouter(prefix="/api/configs", tags=["configs"]) +logger = AppLogger.get_logger(__name__) + + +@router.get("", response_model=list[SalaryConfigResponse]) +def list_configs( + http_request: Request, + config_group: str | None = Query(default=None, description="配置分组:attendance、payroll等"), + current_user: UserORM = Depends(require_permission(PERMISSION_CONFIG_VIEW)), + service: SalaryConfigService = Depends(get_salary_config_service), + operation_logs: OperationLogService = Depends(get_operation_log_service), +) -> list[SalaryConfigResponse]: + """查询系统规则配置。""" + configs = service.list_configs(config_group=config_group) + operation_logs.record( + actor=current_user, + module="system", + action="config.list", + target_type="salary_config", + target_id=config_group or "", + status="success", + detail=f"查询系统配置 count={len(configs)}", + **operation_request_meta(http_request), + ) + return [SalaryConfigResponse.from_orm_model(config) for config in configs] + + +@router.put("/{config_key:path}", response_model=SalaryConfigResponse) +def update_config( + http_request: Request, + config_key: str, + request: SalaryConfigRequest, + current_user: UserORM = Depends(require_permission(PERMISSION_CONFIG_MANAGE)), + service: SalaryConfigService = Depends(get_salary_config_service), + operation_logs: OperationLogService = Depends(get_operation_log_service), +) -> SalaryConfigResponse: + """更新单个规则配置。""" + try: + config = service.update_config(config_key, request.to_update()) + except (ValueError, TypeError) as exc: + operation_logs.record( + actor=current_user, + module="system", + action="config.update", + target_type="salary_config", + target_id=config_key, + status="failed", + detail=str(exc), + **operation_request_meta(http_request), + ) + logger.error("更新系统配置失败 config_key=%s reason=%s", config_key, exc) + raise HTTPException(status_code=400, detail=str(exc)) from exc + operation_logs.record( + actor=current_user, + module="system", + action="config.update", + target_type="salary_config", + target_id=config_key, + status="success", + detail="更新系统配置成功", + **operation_request_meta(http_request), + ) + return SalaryConfigResponse.from_orm_model(config) + + +@router.post("/defaults", response_model=SalaryConfigDefaultsResponse) +def ensure_default_configs( + http_request: Request, + current_user: UserORM = Depends(require_permission(PERMISSION_CONFIG_MANAGE)), + service: SalaryConfigService = Depends(get_salary_config_service), + operation_logs: OperationLogService = Depends(get_operation_log_service), +) -> SalaryConfigDefaultsResponse: + """补齐系统默认规则配置。""" + count = service.ensure_defaults() + operation_logs.record( + actor=current_user, + module="system", + action="config.defaults", + target_type="salary_config", + target_id="defaults", + status="success", + detail=f"补齐默认配置 created={count}", + **operation_request_meta(http_request), + ) + return SalaryConfigDefaultsResponse(created_count=count, message="默认配置已检查") diff --git a/financial_system/api/routers/employees.py b/financial_system/api/routers/employees.py new file mode 100644 index 0000000..a57ff43 --- /dev/null +++ b/financial_system/api/routers/employees.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query, Request + +from ...core.logger import AppLogger +from ...core.permissions import PERMISSION_EMPLOYEE_MANAGE, PERMISSION_EMPLOYEE_VIEW +from ...database.orm import UserORM +from ...services import EmployeeService, OperationLogService +from ..dependencies import get_employee_service, get_operation_log_service, require_permission +from ..request_meta import operation_request_meta +from ..schemas.employee import EmployeeListResponse, EmployeeNoResponse, EmployeeRequest, EmployeeResponse, EmployeeUpdateRequest + +router = APIRouter(prefix="/api/employees", tags=["employees"]) +logger = AppLogger.get_logger(__name__) + + +@router.get("/next-no", response_model=EmployeeNoResponse) +def preview_next_employee_no( + http_request: Request, + current_user: UserORM = Depends(require_permission(PERMISSION_EMPLOYEE_MANAGE)), + service: EmployeeService = Depends(get_employee_service), + operation_logs: OperationLogService = Depends(get_operation_log_service), +) -> EmployeeNoResponse: + """预览下一个系统员工编号。""" + employee_no = service.preview_next_employee_no() + operation_logs.record( + actor=current_user, + module="system", + action="employee.next_no", + target_type="employee", + target_id=employee_no, + status="success", + detail=f"预览员工编号 employee_no={employee_no}", + **operation_request_meta(http_request), + ) + return EmployeeNoResponse(employee_no=employee_no) + + +@router.get("", response_model=list[EmployeeResponse]) +def list_employees( + http_request: Request, + keyword: str | None = Query(default=None, description="员工编号、姓名、部门、岗位等关键字"), + employment_status: str | None = Query(default=None, description="员工状态:active或inactive"), + current_user: UserORM = Depends(require_permission(PERMISSION_EMPLOYEE_VIEW)), + service: EmployeeService = Depends(get_employee_service), + operation_logs: OperationLogService = Depends(get_operation_log_service), +) -> list[EmployeeResponse]: + """查询员工档案。""" + try: + employees = service.list_employees(keyword=keyword, employment_status=employment_status) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + operation_logs.record( + actor=current_user, + module="system", + action="employee.list", + target_type="employee", + target_id="", + status="success", + detail=f"查询员工列表 count={len(employees)}", + **operation_request_meta(http_request), + ) + return [EmployeeResponse.from_orm_model(employee) for employee in employees] + + +@router.get("/page", response_model=EmployeeListResponse) +def list_employees_page( + http_request: Request, + page: int = Query(default=1, ge=1, description="页码,从1开始"), + page_size: int = Query(default=10, ge=1, le=100, description="每页条数,最大100"), + keyword: str | None = Query(default=None, description="员工编号、姓名、部门、岗位等关键字"), + employment_status: str | None = Query(default=None, description="员工状态:active或inactive"), + current_user: UserORM = Depends(require_permission(PERMISSION_EMPLOYEE_VIEW)), + service: EmployeeService = Depends(get_employee_service), + operation_logs: OperationLogService = Depends(get_operation_log_service), +) -> EmployeeListResponse: + """分页查询员工档案,员工维护页面使用。""" + try: + page_data = service.list_employees_page( + page=page, + page_size=page_size, + keyword=keyword, + employment_status=employment_status, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + operation_logs.record( + actor=current_user, + module="system", + action="employee.page", + target_type="employee", + target_id="", + status="success", + detail=f"分页查询员工列表 total={page_data.total} page={page_data.page} page_size={page_data.page_size}", + **operation_request_meta(http_request), + ) + return EmployeeListResponse.from_page(page_data) + + +@router.get("/{employee_id}", response_model=EmployeeResponse) +def get_employee( + http_request: Request, + employee_id: int, + current_user: UserORM = Depends(require_permission(PERMISSION_EMPLOYEE_VIEW)), + service: EmployeeService = Depends(get_employee_service), + operation_logs: OperationLogService = Depends(get_operation_log_service), +) -> EmployeeResponse: + """查询员工详情,供编辑时回填最新数据。""" + try: + employee = service.get_employee(employee_id) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + operation_logs.record( + actor=current_user, + module="system", + action="employee.detail", + target_type="employee", + target_id=str(employee.id), + status="success", + detail=f"查询员工详情 employee_no={employee.employee_no} name={employee.name}", + **operation_request_meta(http_request), + ) + return EmployeeResponse.from_orm_model(employee) + + +@router.post("", response_model=EmployeeResponse) +def create_employee( + http_request: Request, + request: EmployeeRequest, + current_user: UserORM = Depends(require_permission(PERMISSION_EMPLOYEE_MANAGE)), + service: EmployeeService = Depends(get_employee_service), + operation_logs: OperationLogService = Depends(get_operation_log_service), +) -> EmployeeResponse: + """新增员工档案。""" + try: + employee = service.create_employee(**request.model_dump()) + except ValueError as exc: + operation_logs.record( + actor=current_user, + module="system", + action="employee.create", + target_type="employee", + target_id=request.employee_no, + status="failed", + detail=str(exc), + **operation_request_meta(http_request), + ) + logger.error("新增员工失败 employee_no=%s reason=%s", request.employee_no, exc) + raise HTTPException(status_code=400, detail=str(exc)) from exc + operation_logs.record( + actor=current_user, + module="system", + action="employee.create", + target_type="employee", + target_id=str(employee.id), + status="success", + detail=f"新增员工成功 employee_no={employee.employee_no} name={employee.name}", + **operation_request_meta(http_request), + ) + return EmployeeResponse.from_orm_model(employee) + + +@router.patch("/{employee_id}", response_model=EmployeeResponse) +@router.put("/{employee_id}", response_model=EmployeeResponse) +def update_employee( + http_request: Request, + employee_id: int, + request: EmployeeUpdateRequest, + current_user: UserORM = Depends(require_permission(PERMISSION_EMPLOYEE_MANAGE)), + service: EmployeeService = Depends(get_employee_service), + operation_logs: OperationLogService = Depends(get_operation_log_service), +) -> EmployeeResponse: + """更新员工档案。""" + try: + employee = service.update_employee(employee_id, **request.model_dump(exclude_unset=True)) + except ValueError as exc: + operation_logs.record( + actor=current_user, + module="system", + action="employee.update", + target_type="employee", + target_id=str(employee_id), + status="failed", + detail=str(exc), + **operation_request_meta(http_request), + ) + logger.error("更新员工失败 employee_id=%s reason=%s", employee_id, exc) + raise HTTPException(status_code=400, detail=str(exc)) from exc + operation_logs.record( + actor=current_user, + module="system", + action="employee.update", + target_type="employee", + target_id=str(employee.id), + status="success", + detail=f"更新员工成功 employee_no={employee.employee_no} name={employee.name}", + **operation_request_meta(http_request), + ) + return EmployeeResponse.from_orm_model(employee) diff --git a/financial_system/api/routers/health.py b/financial_system/api/routers/health.py new file mode 100644 index 0000000..cdbe4ff --- /dev/null +++ b/financial_system/api/routers/health.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from fastapi import APIRouter + +router = APIRouter(tags=["health"]) + + +@router.get("/health") +def health() -> dict[str, str]: + return {"status": "ok"} diff --git a/financial_system/api/routers/operation_logs.py b/financial_system/api/routers/operation_logs.py new file mode 100644 index 0000000..46cfd93 --- /dev/null +++ b/financial_system/api/routers/operation_logs.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from datetime import datetime + +from fastapi import APIRouter, Depends, Query, Request + +from ...core.logger import AppLogger +from ...core.permissions import PERMISSION_OPERATION_LOG_VIEW +from ...database.orm import UserORM +from ...services import OperationLogService +from ..dependencies import get_operation_log_service, require_permission +from ..request_meta import operation_request_meta +from ..schemas.operation_log import OperationLogListResponse + +router = APIRouter(prefix="/api/operation-logs", tags=["operation-logs"]) +logger = AppLogger.get_logger(__name__) + + +@router.get("", response_model=OperationLogListResponse) +def list_operation_logs( + http_request: Request, + page: int = Query(default=1, ge=1, description="页码,从1开始"), + page_size: int = Query(default=20, ge=1, le=100, description="每页条数,最大100"), + username: str | None = Query(default=None, description="按用户名模糊筛选"), + module: str | None = Query(default=None, description="按模块精确筛选"), + action: str | None = Query(default=None, description="按动作精确筛选"), + status: str | None = Query(default=None, description="按状态筛选:success或failed"), + keyword: str | None = Query(default=None, description="关键字模糊筛选"), + start_time: datetime | None = Query(default=None, description="开始时间"), + end_time: datetime | None = Query(default=None, description="结束时间"), + current_user: UserORM = Depends(require_permission(PERMISSION_OPERATION_LOG_VIEW)), + service: OperationLogService = Depends(get_operation_log_service), +) -> OperationLogListResponse: + """查看操作日志,支持分页和常用筛选。""" + page_data = service.list_logs( + page=page, + page_size=page_size, + username=username, + module=module, + action=action, + status=status, + keyword=keyword, + start_time=start_time, + end_time=end_time, + ) + service.record( + actor=current_user, + module="system", + action="operation_log.view", + target_type="operation_log", + target_id="", + status="success", + detail=f"查看操作日志 page={page_data.page} page_size={page_data.page_size} total={page_data.total}", + **operation_request_meta(http_request), + ) + logger.info("操作日志接口查询成功 user_id=%s total=%s", current_user.id, page_data.total) + return OperationLogListResponse.from_page(page_data) diff --git a/financial_system/api/routers/organization.py b/financial_system/api/routers/organization.py new file mode 100644 index 0000000..680e0b7 --- /dev/null +++ b/financial_system/api/routers/organization.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query, Request + +from ...core.logger import AppLogger +from ...core.permissions import PERMISSION_ORGANIZATION_MANAGE, PERMISSION_ORGANIZATION_VIEW +from ...database.orm import UserORM +from ...services import OperationLogService, OrganizationService +from ..dependencies import get_operation_log_service, get_organization_service, require_permission +from ..request_meta import operation_request_meta +from ..schemas.organization import DepartmentRequest, DepartmentResponse, PositionRequest, PositionResponse + +router = APIRouter(prefix="/api/organization", tags=["organization"]) +logger = AppLogger.get_logger(__name__) + + +@router.get("/departments", response_model=list[DepartmentResponse]) +def list_departments( + http_request: Request, + active_only: bool = Query(default=True, description="是否只查询启用部门"), + keyword: str | None = Query(default=None, description="部门编码或名称关键字"), + current_user: UserORM = Depends(require_permission(PERMISSION_ORGANIZATION_VIEW)), + service: OrganizationService = Depends(get_organization_service), + operation_logs: OperationLogService = Depends(get_operation_log_service), +) -> list[DepartmentResponse]: + """查询部门列表。""" + departments = service.list_departments(active_only=active_only, keyword=keyword) + operation_logs.record( + actor=current_user, + module="system", + action="organization.department.list", + target_type="department", + target_id="", + status="success", + detail=f"查询部门 count={len(departments)}", + **operation_request_meta(http_request), + ) + return [DepartmentResponse.from_orm_model(department) for department in departments] + + +@router.post("/departments", response_model=DepartmentResponse) +def create_department( + http_request: Request, + request: DepartmentRequest, + current_user: UserORM = Depends(require_permission(PERMISSION_ORGANIZATION_MANAGE)), + service: OrganizationService = Depends(get_organization_service), + operation_logs: OperationLogService = Depends(get_operation_log_service), +) -> DepartmentResponse: + """新增部门。""" + try: + department = service.create_department(**request.model_dump()) + except ValueError as exc: + logger.error("新增部门失败 name=%s reason=%s", request.name, exc) + raise HTTPException(status_code=400, detail=str(exc)) from exc + operation_logs.record( + actor=current_user, + module="system", + action="organization.department.create", + target_type="department", + target_id=str(department.id), + status="success", + detail=f"新增部门 name={department.name}", + **operation_request_meta(http_request), + ) + return DepartmentResponse.from_orm_model(department) + + +@router.put("/departments/{department_id}", response_model=DepartmentResponse) +def update_department( + http_request: Request, + department_id: int, + request: DepartmentRequest, + current_user: UserORM = Depends(require_permission(PERMISSION_ORGANIZATION_MANAGE)), + service: OrganizationService = Depends(get_organization_service), + operation_logs: OperationLogService = Depends(get_operation_log_service), +) -> DepartmentResponse: + """更新部门。""" + try: + department = service.update_department(department_id, **request.model_dump()) + except ValueError as exc: + logger.error("更新部门失败 department_id=%s reason=%s", department_id, exc) + raise HTTPException(status_code=400, detail=str(exc)) from exc + operation_logs.record( + actor=current_user, + module="system", + action="organization.department.update", + target_type="department", + target_id=str(department.id), + status="success", + detail=f"更新部门 name={department.name}", + **operation_request_meta(http_request), + ) + return DepartmentResponse.from_orm_model(department) + + +@router.get("/positions", response_model=list[PositionResponse]) +def list_positions( + http_request: Request, + department_id: int | None = Query(default=None, description="部门ID"), + active_only: bool = Query(default=True, description="是否只查询启用岗位"), + keyword: str | None = Query(default=None, description="岗位编码或名称关键字"), + current_user: UserORM = Depends(require_permission(PERMISSION_ORGANIZATION_VIEW)), + service: OrganizationService = Depends(get_organization_service), + operation_logs: OperationLogService = Depends(get_operation_log_service), +) -> list[PositionResponse]: + """查询岗位列表。""" + try: + positions = service.list_positions(department_id=department_id, active_only=active_only, keyword=keyword) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + operation_logs.record( + actor=current_user, + module="system", + action="organization.position.list", + target_type="position", + target_id=str(department_id or ""), + status="success", + detail=f"查询岗位 count={len(positions)}", + **operation_request_meta(http_request), + ) + return [PositionResponse.from_orm_model(position) for position in positions] + + +@router.post("/positions", response_model=PositionResponse) +def create_position( + http_request: Request, + request: PositionRequest, + current_user: UserORM = Depends(require_permission(PERMISSION_ORGANIZATION_MANAGE)), + service: OrganizationService = Depends(get_organization_service), + operation_logs: OperationLogService = Depends(get_operation_log_service), +) -> PositionResponse: + """新增岗位。""" + try: + position = service.create_position(**request.model_dump()) + except ValueError as exc: + logger.error("新增岗位失败 name=%s reason=%s", request.name, exc) + raise HTTPException(status_code=400, detail=str(exc)) from exc + operation_logs.record( + actor=current_user, + module="system", + action="organization.position.create", + target_type="position", + target_id=str(position.id), + status="success", + detail=f"新增岗位 name={position.name}", + **operation_request_meta(http_request), + ) + return PositionResponse.from_orm_model(position) + + +@router.put("/positions/{position_id}", response_model=PositionResponse) +def update_position( + http_request: Request, + position_id: int, + request: PositionRequest, + current_user: UserORM = Depends(require_permission(PERMISSION_ORGANIZATION_MANAGE)), + service: OrganizationService = Depends(get_organization_service), + operation_logs: OperationLogService = Depends(get_operation_log_service), +) -> PositionResponse: + """更新岗位。""" + try: + position = service.update_position(position_id, **request.model_dump()) + except ValueError as exc: + logger.error("更新岗位失败 position_id=%s reason=%s", position_id, exc) + raise HTTPException(status_code=400, detail=str(exc)) from exc + operation_logs.record( + actor=current_user, + module="system", + action="organization.position.update", + target_type="position", + target_id=str(position.id), + status="success", + detail=f"更新岗位 name={position.name}", + **operation_request_meta(http_request), + ) + return PositionResponse.from_orm_model(position) diff --git a/financial_system/api/routers/payroll.py b/financial_system/api/routers/payroll.py new file mode 100644 index 0000000..4322827 --- /dev/null +++ b/financial_system/api/routers/payroll.py @@ -0,0 +1,239 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile +from fastapi.responses import FileResponse + +from ...core.logger import AppLogger +from ...core.permissions import ( + PERMISSION_PAYROLL_DINGTALK_CALCULATE, + PERMISSION_PAYROLL_EXCEL_CALCULATE, + PERMISSION_PAYROLL_FILE_DOWNLOAD, + PERMISSION_PAYROLL_JOB_VIEW, +) +from ...database.orm import UserORM +from ...integrations.dingtalk import DingTalkError +from ...services import OperationLogService, PayrollApplicationService +from ..dependencies import get_operation_log_service, get_payroll_service, require_permission +from ..request_meta import operation_request_meta +from ..schemas.payroll import ( + DingTalkPayrollRequest, + DingTalkPayrollResponse, + ExcelPayrollResponse, + PayrollJobResponse, +) + +router = APIRouter(prefix="/api/payroll", tags=["payroll"]) +logger = AppLogger.get_logger(__name__) + + +@router.post( + "/excel", + response_model=ExcelPayrollResponse, +) +def calculate_from_excel( + http_request: Request, + file: UploadFile = File(...), + config_path: str | None = Form(default=None), + export_excel: bool = Form(default=True), + current_user: UserORM = Depends(require_permission(PERMISSION_PAYROLL_EXCEL_CALCULATE)), + service: PayrollApplicationService = Depends(get_payroll_service), + operation_logs: OperationLogService = Depends(get_operation_log_service), +) -> ExcelPayrollResponse: + """上传月度汇总表并立即计算,计算任务和员工结果都会落库。""" + try: + computation = service.calculate_from_excel_upload( + filename=file.filename or "attendance.xlsx", + stream=file.file, + config_path=config_path, + export_excel=export_excel, + ) + except ValueError as exc: + operation_logs.record( + actor=current_user, + module="payroll", + action="payroll.excel.calculate", + target_type="payroll_job", + target_id="", + status="failed", + detail=f"Excel工资计算失败 filename={file.filename} reason={exc}", + **operation_request_meta(http_request), + ) + logger.error("Excel 工资计算接口失败 filename=%s reason=%s", file.filename, exc) + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: + operation_logs.record( + actor=current_user, + module="payroll", + action="payroll.excel.calculate", + target_type="payroll_job", + target_id="", + status="failed", + detail=f"Excel工资计算异常 filename={file.filename} reason={exc}", + **operation_request_meta(http_request), + ) + raise + operation_logs.record( + actor=current_user, + module="payroll", + action="payroll.excel.calculate", + target_type="payroll_job", + target_id=computation.job_id, + status="success", + detail=f"Excel工资计算成功 filename={file.filename} employee_count={computation.employee_count}", + **operation_request_meta(http_request), + ) + return ExcelPayrollResponse.from_computation(computation) + + +@router.post( + "/dingtalk/realtime", + response_model=DingTalkPayrollResponse, +) +async def calculate_from_dingtalk( + http_request: Request, + request: DingTalkPayrollRequest, + current_user: UserORM = Depends(require_permission(PERMISSION_PAYROLL_DINGTALK_CALCULATE)), + service: PayrollApplicationService = Depends(get_payroll_service), + operation_logs: OperationLogService = Depends(get_operation_log_service), +) -> DingTalkPayrollResponse: + """从钉钉实时拉打卡记录,再复用同一套工资计算规则。""" + try: + computation = await service.calculate_from_dingtalk_realtime( + user_ids=request.user_ids, + start_date=request.start_date, + end_date=request.end_date, + config_path=request.config_path, + export_excel=request.export_excel, + ) + except ValueError as exc: + operation_logs.record( + actor=current_user, + module="payroll", + action="payroll.dingtalk.calculate", + target_type="payroll_job", + target_id="", + status="failed", + detail=f"钉钉工资计算参数错误 user_count={len(request.user_ids)} reason={exc}", + **operation_request_meta(http_request), + ) + logger.error("钉钉工资计算接口参数错误 user_count=%s reason=%s", len(request.user_ids), exc) + raise HTTPException(status_code=400, detail=str(exc)) from exc + except DingTalkError as exc: + operation_logs.record( + actor=current_user, + module="payroll", + action="payroll.dingtalk.calculate", + target_type="payroll_job", + target_id="", + status="failed", + detail=f"钉钉工资计算调用失败 user_count={len(request.user_ids)} reason={exc}", + **operation_request_meta(http_request), + ) + logger.error("钉钉工资计算接口调用失败 user_count=%s reason=%s", len(request.user_ids), exc) + raise HTTPException(status_code=502, detail=str(exc)) from exc + except Exception as exc: + operation_logs.record( + actor=current_user, + module="payroll", + action="payroll.dingtalk.calculate", + target_type="payroll_job", + target_id="", + status="failed", + detail=f"钉钉工资计算异常 user_count={len(request.user_ids)} reason={exc}", + **operation_request_meta(http_request), + ) + raise + operation_logs.record( + actor=current_user, + module="payroll", + action="payroll.dingtalk.calculate", + target_type="payroll_job", + target_id=computation.job_id, + status="success", + detail=f"钉钉工资计算成功 user_count={len(request.user_ids)} employee_count={computation.employee_count}", + **operation_request_meta(http_request), + ) + return DingTalkPayrollResponse.from_computation(computation) + + +@router.get( + "/jobs/{job_id}", + response_model=PayrollJobResponse, +) +def get_payroll_job( + http_request: Request, + job_id: str, + current_user: UserORM = Depends(require_permission(PERMISSION_PAYROLL_JOB_VIEW)), + service: PayrollApplicationService = Depends(get_payroll_service), + operation_logs: OperationLogService = Depends(get_operation_log_service), +) -> PayrollJobResponse: + """查看已保存的计算任务及员工级结果。""" + job = service.get_job(job_id) + if job is None: + operation_logs.record( + actor=current_user, + module="payroll", + action="payroll.job.view", + target_type="payroll_job", + target_id=job_id, + status="failed", + detail="计算任务不存在", + **operation_request_meta(http_request), + ) + logger.error("查询工资计算任务失败 job_id=%s reason=not_found", job_id) + raise HTTPException(status_code=404, detail="计算任务不存在") + operation_logs.record( + actor=current_user, + module="payroll", + action="payroll.job.view", + target_type="payroll_job", + target_id=job_id, + status="success", + detail=f"查询工资计算任务成功 status={job.status} employee_count={job.employee_count}", + **operation_request_meta(http_request), + ) + return PayrollJobResponse.from_orm_model(job) + + +@router.get( + "/download/{filename}", +) +def download_payroll_file( + http_request: Request, + filename: str, + current_user: UserORM = Depends(require_permission(PERMISSION_PAYROLL_FILE_DOWNLOAD)), + service: PayrollApplicationService = Depends(get_payroll_service), + operation_logs: OperationLogService = Depends(get_operation_log_service), +) -> FileResponse: + """下载计算时导出的 Excel 结果文件。""" + try: + path = service.resolve_output_file(filename) + except FileNotFoundError as exc: + operation_logs.record( + actor=current_user, + module="payroll", + action="payroll.file.download", + target_type="file", + target_id=filename, + status="failed", + detail=str(exc), + **operation_request_meta(http_request), + ) + logger.error("下载工资结果失败 filename=%s reason=%s", filename, exc) + raise HTTPException(status_code=404, detail=str(exc)) from exc + operation_logs.record( + actor=current_user, + module="payroll", + action="payroll.file.download", + target_type="file", + target_id=filename, + status="success", + detail=f"下载工资结果成功 path={path}", + **operation_request_meta(http_request), + ) + logger.info("下载工资结果成功 filename=%s path=%s", filename, path) + return FileResponse( + path, + filename=path.name, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) diff --git a/financial_system/api/routers/reports.py b/financial_system/api/routers/reports.py new file mode 100644 index 0000000..3acf16c --- /dev/null +++ b/financial_system/api/routers/reports.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query, Request + +from ...core.logger import AppLogger +from ...core.permissions import PERMISSION_REPORT_VIEW +from ...database.orm import UserORM +from ...services import OperationLogService, ReportService +from ..dependencies import get_operation_log_service, get_report_service, require_permission +from ..request_meta import operation_request_meta +from ..schemas.report import PayrollReportResponse + +router = APIRouter(prefix="/api/reports", tags=["reports"]) +logger = AppLogger.get_logger(__name__) + + +@router.get("/payroll-summary", response_model=PayrollReportResponse) +def payroll_summary( + http_request: Request, + salary_month: str | None = Query(default=None, description="工资月份YYYY-MM"), + current_user: UserORM = Depends(require_permission(PERMISSION_REPORT_VIEW)), + service: ReportService = Depends(get_report_service), + operation_logs: OperationLogService = Depends(get_operation_log_service), +) -> PayrollReportResponse: + """查询薪资、考勤和部门维度的统计报表。""" + try: + report = service.payroll_summary(salary_month=salary_month) + except ValueError as exc: + logger.error("查询统计报表失败 salary_month=%s reason=%s", salary_month, exc) + raise HTTPException(status_code=400, detail=str(exc)) from exc + operation_logs.record( + actor=current_user, + module="report", + action="report.payroll_summary", + target_type="salary_record", + target_id=salary_month or "", + status="success", + detail=f"查询工资统计报表 employee_count={report.totals['employee_count']}", + **operation_request_meta(http_request), + ) + return PayrollReportResponse.from_report(report) diff --git a/financial_system/api/routers/salary_profiles.py b/financial_system/api/routers/salary_profiles.py new file mode 100644 index 0000000..4c0eb57 --- /dev/null +++ b/financial_system/api/routers/salary_profiles.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query, Request + +from ...core.logger import AppLogger +from ...core.permissions import PERMISSION_SALARY_PROFILE_MANAGE, PERMISSION_SALARY_PROFILE_VIEW +from ...database.orm import UserORM +from ...services import OperationLogService, SalaryProfileService +from ..dependencies import get_operation_log_service, get_salary_profile_service, require_permission +from ..request_meta import operation_request_meta +from ..schemas.salary_profile import SalaryProfileRequest, SalaryProfileResponse + +router = APIRouter(prefix="/api/salary-profiles", tags=["salary-profiles"]) +logger = AppLogger.get_logger(__name__) + + +@router.get("", response_model=list[SalaryProfileResponse]) +def list_salary_profiles( + http_request: Request, + keyword: str | None = Query(default=None, description="员工编号、姓名、部门或岗位关键字"), + current_user: UserORM = Depends(require_permission(PERMISSION_SALARY_PROFILE_VIEW)), + service: SalaryProfileService = Depends(get_salary_profile_service), + operation_logs: OperationLogService = Depends(get_operation_log_service), +) -> list[SalaryProfileResponse]: + """查询薪资档案。""" + profiles = service.list_profiles(keyword=keyword) + operation_logs.record( + actor=current_user, + module="payroll", + action="salary_profile.list", + target_type="salary_profile", + target_id="", + status="success", + detail=f"查询薪资档案 count={len(profiles)}", + **operation_request_meta(http_request), + ) + return [SalaryProfileResponse.from_orm_model(profile) for profile in profiles] + + +@router.put("/{employee_id}", response_model=SalaryProfileResponse) +def upsert_salary_profile( + http_request: Request, + employee_id: int, + request: SalaryProfileRequest, + current_user: UserORM = Depends(require_permission(PERMISSION_SALARY_PROFILE_MANAGE)), + service: SalaryProfileService = Depends(get_salary_profile_service), + operation_logs: OperationLogService = Depends(get_operation_log_service), +) -> SalaryProfileResponse: + """按员工维护当前薪资档案,包含底薪、提成和独立加班费单价。""" + if request.employee_id != employee_id: + raise HTTPException(status_code=400, detail="路径员工ID与请求员工ID不一致") + try: + profile = service.upsert_profile(**request.model_dump()) + except ValueError as exc: + operation_logs.record( + actor=current_user, + module="payroll", + action="salary_profile.upsert", + target_type="employee", + target_id=str(employee_id), + status="failed", + detail=str(exc), + **operation_request_meta(http_request), + ) + logger.error("保存薪资档案失败 employee_id=%s reason=%s", employee_id, exc) + raise HTTPException(status_code=400, detail=str(exc)) from exc + operation_logs.record( + actor=current_user, + module="payroll", + action="salary_profile.upsert", + target_type="employee", + target_id=str(employee_id), + status="success", + detail=( + f"保存薪资档案成功 base_salary={profile.base_salary} " + f"commission={profile.commission_amount} overtime_rate={profile.overtime_rate}" + ), + **operation_request_meta(http_request), + ) + return SalaryProfileResponse.from_orm_model(profile) diff --git a/financial_system/api/schemas/__init__.py b/financial_system/api/schemas/__init__.py new file mode 100644 index 0000000..1f2ae1f --- /dev/null +++ b/financial_system/api/schemas/__init__.py @@ -0,0 +1,43 @@ +from .auth import ( + ChangePasswordRequest, + ChangePasswordResponse, + CreateUserRequest, + LoginRequest, + LoginResponse, + UserProfileDTO, + UserResponse, +) +from .payroll import ( + DingTalkPayrollRequest, + DingTalkPayrollResponse, + ExcelPayrollResponse, + PayrollJobResponse, + PayrollResultDTO, +) +from .operation_log import OperationLogDTO, OperationLogListResponse +from .employee import EmployeeListResponse, EmployeeNoResponse, EmployeeRequest, EmployeeResponse, EmployeeUpdateRequest +from .salary_profile import SalaryProfileRequest, SalaryProfileResponse + +__all__ = [ + "CreateUserRequest", + "ChangePasswordRequest", + "ChangePasswordResponse", + "DingTalkPayrollRequest", + "DingTalkPayrollResponse", + "EmployeeRequest", + "EmployeeResponse", + "EmployeeListResponse", + "EmployeeNoResponse", + "EmployeeUpdateRequest", + "ExcelPayrollResponse", + "LoginRequest", + "LoginResponse", + "OperationLogDTO", + "OperationLogListResponse", + "PayrollJobResponse", + "PayrollResultDTO", + "SalaryProfileRequest", + "SalaryProfileResponse", + "UserProfileDTO", + "UserResponse", +] diff --git a/financial_system/api/schemas/auth.py b/financial_system/api/schemas/auth.py new file mode 100644 index 0000000..36c4e4c --- /dev/null +++ b/financial_system/api/schemas/auth.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, Field + +from ...core.permissions import MenuPermission, menus_for_role, permissions_for_role +from ...database.orm import UserORM +from ...services import AuthSession + + +class MenuDTO(BaseModel): + """返回给前端的菜单项。""" + + code: str + name: str + path: str + + @classmethod + def from_domain(cls, menu: MenuPermission) -> "MenuDTO": + return cls(code=menu.code, name=menu.name, path=menu.path) + + +class UserProfileDTO(BaseModel): + """当前登录用户及其权限上下文。""" + + id: int + username: str + display_name: str + avatar_url: str + email: str + phone: str + department: str + position_title: str + role: str + is_active: bool + menus: list[MenuDTO] + permissions: list[str] + + @classmethod + def from_orm_model(cls, user: UserORM) -> "UserProfileDTO": + menus, permissions = _role_access(user.role) + return cls( + id=user.id, + username=user.username, + display_name=user.display_name, + avatar_url=user.avatar_url, + email=user.email, + phone=user.phone, + department=user.department, + position_title=user.position_title, + role=user.role, + is_active=user.is_active, + menus=menus, + permissions=permissions, + ) + + +class LoginRequest(BaseModel): + """登录请求。""" + + username: str + password: str + + +class LoginResponse(BaseModel): + """登录响应,前端据此渲染菜单和按钮权限。""" + + access_token: str + token_type: str + user: UserProfileDTO + menus: list[MenuDTO] + permissions: list[str] + + @classmethod + def from_session(cls, session: AuthSession) -> "LoginResponse": + user = UserProfileDTO.from_orm_model(session.user) + return cls( + access_token=session.access_token, + token_type=session.token_type, + user=user, + menus=user.menus, + permissions=user.permissions, + ) + + +class CreateUserRequest(BaseModel): + """创建用户请求,角色只能取 permissions.py 中定义的角色。""" + + username: str = Field(..., min_length=2, max_length=64) + password: str = Field(..., min_length=6, max_length=128) + role: str + display_name: str = "" + email: str = Field(default="", max_length=128) + phone: str = Field(default="", max_length=32) + department: str = Field(default="", max_length=128) + position_title: str = Field(default="", max_length=128) + + +class ChangePasswordRequest(BaseModel): + """当前登录用户修改自己密码请求。""" + + old_password: str = Field(..., min_length=1, max_length=128) + new_password: str = Field(..., min_length=6, max_length=128) + confirm_password: str = Field(..., min_length=6, max_length=128) + + +class ChangePasswordResponse(BaseModel): + """修改密码成功响应。""" + + message: str + + +class UserResponse(BaseModel): + """用户管理列表/创建接口的返回模型。""" + + id: int + username: str + display_name: str + avatar_url: str + email: str + phone: str + department: str + position_title: str + role: str + is_active: bool + created_at: datetime + updated_at: datetime + menus: list[MenuDTO] + permissions: list[str] + + @classmethod + def from_orm_model(cls, user: UserORM) -> "UserResponse": + profile = UserProfileDTO.from_orm_model(user) + return cls( + id=user.id, + username=user.username, + display_name=user.display_name, + avatar_url=user.avatar_url, + email=user.email, + phone=user.phone, + department=user.department, + position_title=user.position_title, + role=user.role, + is_active=user.is_active, + created_at=user.created_at, + updated_at=user.updated_at, + menus=profile.menus, + permissions=profile.permissions, + ) + + +class UpdateProfileRequest(BaseModel): + """当前登录用户修改个人资料请求。""" + + display_name: str = Field(default="", max_length=128) + email: str = Field(default="", max_length=128) + phone: str = Field(default="", max_length=32) + department: str = Field(default="", max_length=128) + position_title: str = Field(default="", max_length=128) + + +def _role_access(role: str) -> tuple[list[MenuDTO], list[str]]: + """统一生成前端菜单和按钮/接口操作权限,避免响应模型各自拼装。""" + return ( + [MenuDTO.from_domain(menu) for menu in menus_for_role(role)], + permissions_for_role(role), + ) diff --git a/financial_system/api/schemas/commission.py b/financial_system/api/schemas/commission.py new file mode 100644 index 0000000..f78fc66 --- /dev/null +++ b/financial_system/api/schemas/commission.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, Field + +from ...database.orm import CommissionRecordORM +from ...services.commission_service import CommissionPage +from .employee import EmployeeResponse + + +class CommissionRecordRequest(BaseModel): + """提成记录维护请求。""" + + employee_id: int + commission_month: str = Field(..., max_length=7) + commission_type: str = Field(default="销售提成", max_length=64) + amount: float = Field(default=0, ge=0) + source_type: str = Field(default="manual", max_length=32) + business_ref: str = Field(default="", max_length=128) + remark: str = "" + + +class CommissionRecordResponse(BaseModel): + """提成记录响应。""" + + id: int + employee_id: int + employee: EmployeeResponse + commission_month: str + commission_type: str + amount: float + source_type: str + business_ref: str + remark: str + created_at: datetime + updated_at: datetime + + @classmethod + def from_orm_model(cls, record: CommissionRecordORM) -> "CommissionRecordResponse": + return cls( + id=record.id, + employee_id=record.employee_id, + employee=EmployeeResponse.from_orm_model(record.employee), + commission_month=record.commission_month, + commission_type=record.commission_type, + amount=record.amount, + source_type=record.source_type, + business_ref=record.business_ref, + remark=record.remark, + created_at=record.created_at, + updated_at=record.updated_at, + ) + + +class CommissionListResponse(BaseModel): + """提成记录分页响应。""" + + items: list[CommissionRecordResponse] + total: int + page: int + page_size: int + + @classmethod + def from_page(cls, page: CommissionPage) -> "CommissionListResponse": + return cls( + items=[CommissionRecordResponse.from_orm_model(item) for item in page.items], + total=page.total, + page=page.page, + page_size=page.page_size, + ) + + +class CommissionImportResponse(BaseModel): + """提成 Excel 导入响应。""" + + imported_count: int + message: str diff --git a/financial_system/api/schemas/employee.py b/financial_system/api/schemas/employee.py new file mode 100644 index 0000000..a3734b8 --- /dev/null +++ b/financial_system/api/schemas/employee.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, Field + +from ...database.orm import EmployeeORM +from ...services import EmployeePage + + +class EmployeeRequest(BaseModel): + """员工维护请求。""" + + employee_no: str = Field(default="", max_length=64) + name: str = Field(..., min_length=1, max_length=128) + dingtalk_user_id: str = Field(default="", max_length=128) + department: str = Field(default="", max_length=128) + position: str = Field(default="", max_length=128) + phone: str = Field(default="", max_length=32) + employment_status: str = Field(default="active", max_length=32) + remark: str = "" + + +class EmployeeUpdateRequest(BaseModel): + """员工编辑请求,员工编号由后端保留,不需要前端维护。""" + + employee_no: str = Field(default="", max_length=64) + name: str | None = Field(default=None, min_length=1, max_length=128) + dingtalk_user_id: str | None = Field(default=None, max_length=128) + department: str | None = Field(default=None, max_length=128) + position: str | None = Field(default=None, max_length=128) + phone: str | None = Field(default=None, max_length=32) + employment_status: str | None = Field(default=None, max_length=32) + remark: str | None = None + + +class EmployeeResponse(BaseModel): + """员工档案响应。""" + + id: int + employee_no: str + name: str + dingtalk_user_id: str + department: str + position: str + phone: str + employment_status: str + remark: str + created_at: datetime + updated_at: datetime + + @classmethod + def from_orm_model(cls, employee: EmployeeORM) -> "EmployeeResponse": + return cls( + id=employee.id, + employee_no=employee.employee_no, + name=employee.name, + dingtalk_user_id=employee.dingtalk_user_id, + department=employee.department, + position=employee.position, + phone=employee.phone, + employment_status=employee.employment_status, + remark=employee.remark, + created_at=employee.created_at, + updated_at=employee.updated_at, + ) + + +class EmployeeNoResponse(BaseModel): + """员工编号响应。""" + + employee_no: str + + +class EmployeeListResponse(BaseModel): + """员工分页列表响应。""" + + items: list[EmployeeResponse] + total: int + page: int + page_size: int + + @classmethod + def from_page(cls, page: EmployeePage) -> "EmployeeListResponse": + return cls( + items=[EmployeeResponse.from_orm_model(employee) for employee in page.items], + total=page.total, + page=page.page, + page_size=page.page_size, + ) diff --git a/financial_system/api/schemas/operation_log.py b/financial_system/api/schemas/operation_log.py new file mode 100644 index 0000000..2e3cefe --- /dev/null +++ b/financial_system/api/schemas/operation_log.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel + +from ...database.orm import OperationLogORM +from ...services import OperationLogPage + + +class OperationLogDTO(BaseModel): + """操作日志列表项。""" + + id: int + user_id: int | None + username: str + role: str + module: str + action: str + target_type: str + target_id: str + status: str + detail: str + ip_address: str + user_agent: str + created_at: datetime + + @classmethod + def from_orm_model(cls, log: OperationLogORM) -> "OperationLogDTO": + return cls( + id=log.id, + user_id=log.user_id, + username=log.username, + role=log.role, + module=log.module, + action=log.action, + target_type=log.target_type, + target_id=log.target_id, + status=log.status, + detail=log.detail, + ip_address=log.ip_address, + user_agent=log.user_agent, + created_at=log.created_at, + ) + + +class OperationLogListResponse(BaseModel): + """操作日志分页响应。""" + + items: list[OperationLogDTO] + total: int + page: int + page_size: int + + @classmethod + def from_page(cls, page: OperationLogPage) -> "OperationLogListResponse": + return cls( + items=[OperationLogDTO.from_orm_model(log) for log in page.items], + total=page.total, + page=page.page, + page_size=page.page_size, + ) diff --git a/financial_system/api/schemas/organization.py b/financial_system/api/schemas/organization.py new file mode 100644 index 0000000..1937bd5 --- /dev/null +++ b/financial_system/api/schemas/organization.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, Field + +from ...database.orm import DepartmentORM, PositionORM + + +class DepartmentRequest(BaseModel): + """部门维护请求。""" + + parent_id: int | None = None + department_code: str = Field(..., max_length=64) + name: str = Field(..., max_length=128) + sort_order: int = 0 + is_active: bool = True + remark: str = "" + + +class DepartmentResponse(BaseModel): + """部门响应。""" + + id: int + parent_id: int | None + department_code: str + name: str + sort_order: int + is_active: bool + remark: str + created_at: datetime + updated_at: datetime + + @classmethod + def from_orm_model(cls, department: DepartmentORM) -> "DepartmentResponse": + return cls( + id=department.id, + parent_id=department.parent_id, + department_code=department.department_code, + name=department.name, + sort_order=department.sort_order, + is_active=department.is_active, + remark=department.remark, + created_at=department.created_at, + updated_at=department.updated_at, + ) + + +class PositionRequest(BaseModel): + """岗位维护请求。""" + + department_id: int + position_code: str = Field(default="", max_length=64) + name: str = Field(..., max_length=128) + sort_order: int = 0 + is_active: bool = True + remark: str = "" + + +class PositionResponse(BaseModel): + """岗位响应。""" + + id: int + department_id: int + department: DepartmentResponse + position_code: str + name: str + sort_order: int + is_active: bool + remark: str + created_at: datetime + updated_at: datetime + + @classmethod + def from_orm_model(cls, position: PositionORM) -> "PositionResponse": + return cls( + id=position.id, + department_id=position.department_id, + department=DepartmentResponse.from_orm_model(position.department), + position_code=position.position_code, + name=position.name, + sort_order=position.sort_order, + is_active=position.is_active, + remark=position.remark, + created_at=position.created_at, + updated_at=position.updated_at, + ) diff --git a/financial_system/api/schemas/payroll.py b/financial_system/api/schemas/payroll.py new file mode 100644 index 0000000..85e3c94 --- /dev/null +++ b/financial_system/api/schemas/payroll.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +from datetime import date, datetime + +from pydantic import BaseModel, Field + +from ...database.orm import PayrollJobORM, PayrollResultORM +from ...domain.models import PayrollResult +from ...services import PayrollComputation + + +class PayrollResultDTO(BaseModel): + """员工级工资结果响应模型。""" + + name: str + department: str + position: str + attendance_group: str + salary_month: str + salary_mode: str + attendance_days: float + absence_days: float + actual_work_hours: float + punch_overtime_hours: float + leave_hours: float + remaining_overtime_hours: float + workday_overtime_hours: float + weekend_overtime_hours: float + holiday_overtime_hours: float + minor_late_count: int + major_late_count: int + penalized_late_count: int + late_deduction: float + missing_card_count: int + missing_card_deduction: float + total_deduction: float + base_salary: float | None + base_pay: float | None + commission_amount: float + overtime_rate: float + weekend_overtime_rate: float + holiday_overtime_rate: float + overtime_pay: float + gross_salary: float | None + net_salary: float | None + note: str + + @classmethod + def from_domain(cls, result: PayrollResult) -> "PayrollResultDTO": + employee = result.employee + return cls( + name=employee.name, + department=employee.department, + position=employee.position, + attendance_group=employee.attendance_group, + salary_month=result.salary_month, + salary_mode=result.salary_mode, + attendance_days=result.attendance_days, + absence_days=result.absence_days, + actual_work_hours=result.actual_work_hours, + punch_overtime_hours=result.punch_overtime_hours, + leave_hours=result.leave_hours, + remaining_overtime_hours=result.remaining_overtime_hours, + workday_overtime_hours=result.workday_overtime_hours, + weekend_overtime_hours=result.weekend_overtime_hours, + holiday_overtime_hours=result.holiday_overtime_hours, + minor_late_count=result.minor_late_count, + major_late_count=result.major_late_count, + penalized_late_count=result.penalized_late_count, + late_deduction=result.late_deduction, + missing_card_count=result.missing_card_count, + missing_card_deduction=result.missing_card_deduction, + total_deduction=result.total_deduction, + base_salary=result.base_salary, + base_pay=result.base_pay, + commission_amount=result.commission_amount, + overtime_rate=result.overtime_rate, + weekend_overtime_rate=result.weekend_overtime_rate, + holiday_overtime_rate=result.holiday_overtime_rate, + overtime_pay=result.overtime_pay, + gross_salary=result.gross_salary, + net_salary=result.net_salary, + note=result.note, + ) + + @classmethod + def from_orm_model(cls, result: PayrollResultORM) -> "PayrollResultDTO": + return cls( + name=result.name, + department=result.department, + position=result.position, + attendance_group=result.attendance_group, + salary_month=result.salary_month, + salary_mode=result.salary_mode, + attendance_days=result.attendance_days, + absence_days=result.absence_days, + actual_work_hours=result.actual_work_hours, + punch_overtime_hours=result.punch_overtime_hours, + leave_hours=result.leave_hours, + remaining_overtime_hours=result.remaining_overtime_hours, + workday_overtime_hours=result.workday_overtime_hours, + weekend_overtime_hours=result.weekend_overtime_hours, + holiday_overtime_hours=result.holiday_overtime_hours, + minor_late_count=result.minor_late_count, + major_late_count=result.major_late_count, + penalized_late_count=result.penalized_late_count, + late_deduction=result.late_deduction, + missing_card_count=result.missing_card_count, + missing_card_deduction=result.missing_card_deduction, + total_deduction=result.total_deduction, + base_salary=result.base_salary, + base_pay=result.base_pay, + commission_amount=result.commission_amount, + overtime_rate=result.overtime_rate, + weekend_overtime_rate=result.weekend_overtime_rate, + holiday_overtime_rate=result.holiday_overtime_rate, + overtime_pay=result.overtime_pay, + gross_salary=result.gross_salary, + net_salary=result.net_salary, + note=result.note, + ) + + +class PayrollResponseBase(BaseModel): + """Excel 和钉钉计算接口共用的响应结构。""" + + job_id: str + employee_count: int + output_file: str | None = None + download_url: str | None = None + results: list[PayrollResultDTO] + + @classmethod + def from_computation(cls, computation: PayrollComputation): + output_filename = computation.output_filename + return cls( + job_id=computation.job_id, + employee_count=computation.employee_count, + output_file=computation.output_file, + download_url=f"/api/payroll/download/{output_filename}" if output_filename else None, + results=[PayrollResultDTO.from_domain(result) for result in computation.results], + ) + + +class ExcelPayrollResponse(PayrollResponseBase): + pass + + +class DingTalkPayrollRequest(BaseModel): + """钉钉实时计算请求参数。""" + + user_ids: list[str] = Field(..., description="钉钉员工 userId 列表") + start_date: date + end_date: date + config_path: str | None = None + export_excel: bool = True + + +class DingTalkPayrollResponse(PayrollResponseBase): + pass + + +class PayrollJobResponse(BaseModel): + """已落库工资计算任务的查询响应。""" + + job_id: str + source_type: str + status: str + input_file: str | None + output_file: str | None + employee_count: int + error_message: str | None + created_at: datetime + updated_at: datetime + results: list[PayrollResultDTO] + + @classmethod + def from_orm_model(cls, job: PayrollJobORM) -> "PayrollJobResponse": + return cls( + job_id=job.id, + source_type=job.source_type, + status=job.status, + input_file=job.input_file, + output_file=job.output_file, + employee_count=job.employee_count, + error_message=job.error_message, + created_at=job.created_at, + updated_at=job.updated_at, + results=[PayrollResultDTO.from_orm_model(result) for result in job.results], + ) diff --git a/financial_system/api/schemas/report.py b/financial_system/api/schemas/report.py new file mode 100644 index 0000000..2af1f8e --- /dev/null +++ b/financial_system/api/schemas/report.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel + +from ...database.orm import SalaryRecordORM +from ...services.report_service import PayrollReport + + +class DepartmentPayrollReportItem(BaseModel): + department: str + employee_count: int + gross_salary: float + net_salary: float + + +class SalaryRecordSummary(BaseModel): + id: int + employee_no: str + employee_name: str + department: str + salary_month: str + salary_mode: str + base_pay: float + commission_amount: float + overtime_pay: float + deduction_amount: float + gross_salary: float + net_salary: float + created_at: datetime + + @classmethod + def from_orm_model(cls, record: SalaryRecordORM) -> "SalaryRecordSummary": + return cls( + id=record.id, + employee_no=record.employee_no, + employee_name=record.employee_name, + department=record.department, + salary_month=record.salary_month, + salary_mode=record.salary_mode, + base_pay=record.base_pay, + commission_amount=record.commission_amount, + overtime_pay=record.overtime_pay, + deduction_amount=record.deduction_amount, + gross_salary=record.gross_salary, + net_salary=record.net_salary, + created_at=record.created_at, + ) + + +class PayrollReportResponse(BaseModel): + salary_month: str | None + totals: dict[str, float] + attendance: dict[str, float] + departments: list[DepartmentPayrollReportItem] + recent_records: list[SalaryRecordSummary] + + @classmethod + def from_report(cls, report: PayrollReport) -> "PayrollReportResponse": + return cls( + salary_month=report.salary_month, + totals=report.totals, + attendance=report.attendance, + departments=[ + DepartmentPayrollReportItem( + department=str(item["department"]), + employee_count=int(item["employee_count"]), + gross_salary=float(item["gross_salary"]), + net_salary=float(item["net_salary"]), + ) + for item in report.departments + ], + recent_records=[SalaryRecordSummary.from_orm_model(record) for record in report.recent_records], + ) diff --git a/financial_system/api/schemas/salary_config.py b/financial_system/api/schemas/salary_config.py new file mode 100644 index 0000000..7783c7d --- /dev/null +++ b/financial_system/api/schemas/salary_config.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, Field + +from ...database.orm import SalaryConfigORM +from ...services.salary_config_service import SalaryConfigUpdate + + +class SalaryConfigRequest(BaseModel): + """系统配置更新请求。""" + + config_name: str = Field(..., max_length=128) + config_group: str = Field(..., max_length=64) + config_value: str + value_type: str = Field(default="string", max_length=32) + is_enabled: bool = True + remark: str = "" + + def to_update(self) -> SalaryConfigUpdate: + return SalaryConfigUpdate( + config_name=self.config_name, + config_group=self.config_group, + config_value=self.config_value, + value_type=self.value_type, + is_enabled=self.is_enabled, + remark=self.remark, + ) + + +class SalaryConfigResponse(BaseModel): + """系统配置响应。""" + + id: int + config_key: str + config_name: str + config_group: str + config_value: str + value_type: str + is_enabled: bool + remark: str + created_at: datetime + updated_at: datetime + + @classmethod + def from_orm_model(cls, config: SalaryConfigORM) -> "SalaryConfigResponse": + return cls( + id=config.id, + config_key=config.config_key, + config_name=config.config_name, + config_group=config.config_group, + config_value=config.config_value, + value_type=config.value_type, + is_enabled=config.is_enabled, + remark=config.remark, + created_at=config.created_at, + updated_at=config.updated_at, + ) + + +class SalaryConfigDefaultsResponse(BaseModel): + """默认配置初始化响应。""" + + created_count: int + message: str diff --git a/financial_system/api/schemas/salary_profile.py b/financial_system/api/schemas/salary_profile.py new file mode 100644 index 0000000..7127e01 --- /dev/null +++ b/financial_system/api/schemas/salary_profile.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, Field + +from ...database.orm import SalaryProfileORM +from .employee import EmployeeResponse + + +class SalaryProfileRequest(BaseModel): + """薪资档案维护请求。""" + + employee_id: int + salary_mode: str = Field(default="monthly", max_length=32) + base_salary: float = Field(default=0, ge=0) + hourly_rate: float = Field(default=0, ge=0) + piece_quantity: float = Field(default=0, ge=0) + piece_unit_price: float = Field(default=0, ge=0) + probation_type: str = Field(default="ratio", max_length=32) + probation_ratio: float = Field(default=1, ge=0) + probation_salary: float = Field(default=0, ge=0) + commission_amount: float = Field(default=0, ge=0) + overtime_rate: float = Field(default=0, ge=0) + weekend_overtime_rate: float = Field(default=0, ge=0) + holiday_overtime_rate: float = Field(default=0, ge=0) + effective_month: str = Field(default="", max_length=7) + remark: str = "" + + +class SalaryProfileResponse(BaseModel): + """薪资档案响应。""" + + id: int + employee_id: int + employee: EmployeeResponse + salary_mode: str + base_salary: float + hourly_rate: float + piece_quantity: float + piece_unit_price: float + probation_type: str + probation_ratio: float + probation_salary: float + commission_amount: float + overtime_rate: float + weekend_overtime_rate: float + holiday_overtime_rate: float + effective_month: str + remark: str + created_at: datetime + updated_at: datetime + + @classmethod + def from_orm_model(cls, profile: SalaryProfileORM) -> "SalaryProfileResponse": + return cls( + id=profile.id, + employee_id=profile.employee_id, + employee=EmployeeResponse.from_orm_model(profile.employee), + salary_mode=profile.salary_mode, + base_salary=profile.base_salary, + hourly_rate=profile.hourly_rate, + piece_quantity=profile.piece_quantity, + piece_unit_price=profile.piece_unit_price, + probation_type=profile.probation_type, + probation_ratio=profile.probation_ratio, + probation_salary=profile.probation_salary, + commission_amount=profile.commission_amount, + overtime_rate=profile.overtime_rate, + weekend_overtime_rate=profile.weekend_overtime_rate, + holiday_overtime_rate=profile.holiday_overtime_rate, + effective_month=profile.effective_month, + remark=profile.remark, + created_at=profile.created_at, + updated_at=profile.updated_at, + ) diff --git a/financial_system/cli/__init__.py b/financial_system/cli/__init__.py new file mode 100644 index 0000000..6c86c73 --- /dev/null +++ b/financial_system/cli/__init__.py @@ -0,0 +1,5 @@ +"""Command line entry points.""" + +from .payroll import main + +__all__ = ["main"] diff --git a/financial_system/cli/payroll.py b/financial_system/cli/payroll.py new file mode 100644 index 0000000..4e436ff --- /dev/null +++ b/financial_system/cli/payroll.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import argparse +from pathlib import Path + +from ..core.config import load_config +from ..domain.calculator import PayrollCalculator +from ..io.exporter import export_payroll_results +from ..io.parser import MonthlySummaryParser + + +DEFAULT_OUTPUT = Path("outputs/payroll_result.xlsx") +DEFAULT_INPUT = Path( + "/Users/jiaolongyan/Library/Containers/com.tencent.xinWeChat/Data/Documents/" + "xwechat_files/wxid_iy9g88794bjh22_e458/msg/file/2026-06/" + "宁波兆安流体科技有限公司_月度汇总_20260501-20260531.xlsx" +) + + +def build_parser() -> argparse.ArgumentParser: + """命令行入口保留给本地批处理 Excel,不参与 Web API。""" + parser = argparse.ArgumentParser(description="根据月度考勤汇总 Excel 计算工资和考勤扣款。") + parser.add_argument( + "input", + nargs="?", + help="月度汇总 Excel 路径;不传时默认使用本次提供的 2026-05 月度汇总表", + ) + parser.add_argument( + "-c", + "--config", + help="工资规则 JSON。未提供时使用内置规则;示例见 config/salary_rules.example.json", + ) + parser.add_argument( + "-o", + "--output", + default=str(DEFAULT_OUTPUT), + help=f"输出 Excel 路径,默认:{DEFAULT_OUTPUT}", + ) + return parser + + +def main() -> None: + """直接运行脚本时生成一份工资 Excel。""" + args = build_parser().parse_args() + config = load_config(args.config) + input_path = Path(args.input) if args.input else DEFAULT_INPUT + if not input_path.exists(): + raise FileNotFoundError( + f"未找到输入 Excel:{input_path}\n" + "请在运行参数里传入月度汇总 Excel 路径,例如:python main.py /path/to/月度汇总.xlsx" + ) + + parser = MonthlySummaryParser(config.attendance) + employees = parser.parse(input_path) + + calculator = PayrollCalculator(config) + results = calculator.calculate(employees) + + output_path = export_payroll_results(results, config, args.output) + print(f"已生成工资计算结果:{output_path.resolve()}") + print(f"员工数:{len(results)}") diff --git a/financial_system/core/__init__.py b/financial_system/core/__init__.py new file mode 100644 index 0000000..c3eb856 --- /dev/null +++ b/financial_system/core/__init__.py @@ -0,0 +1 @@ +"""Core configuration, security, settings, and permissions.""" diff --git a/financial_system/core/config.py b/financial_system/core/config.py new file mode 100644 index 0000000..d89f302 --- /dev/null +++ b/financial_system/core/config.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path + +from ..domain.models import EmployeePayConfig + + +@dataclass(frozen=True) +class AttendanceRules: + """考勤规则配置,默认值来自当前工资计算约定。""" + + on_work_time: str = "08:30" + off_work_time: str = "17:00" + overtime_round_minutes: int = 60 + standard_daily_hours: float = 8 + minor_late_minutes: int = 5 + minor_late_free_times: int = 3 + late_penalty: float = 30 + missing_card_penalty: float = 20 + weekend_days: tuple[int, ...] = (5, 6) + legal_holidays: tuple[str, ...] = () + leave_keywords: tuple[str, ...] = ( + "调休", + "请假", + "事假", + "病假", + "年假", + "婚假", + "产假", + "陪产假", + "丧假", + ) + + +@dataclass(frozen=True) +class PayrollRules: + default_overtime_rate: float = 0 + default_weekend_overtime_rate: float = 0 + default_holiday_overtime_rate: float = 0 + employees: dict[str, EmployeePayConfig] = field(default_factory=dict) + + +@dataclass(frozen=True) +class AppConfig: + attendance: AttendanceRules = field(default_factory=AttendanceRules) + payroll: PayrollRules = field(default_factory=PayrollRules) + + +def load_config(path: str | Path | None) -> AppConfig: + """加载可选 JSON 配置;未传路径时使用内置默认规则。""" + if not path: + return AppConfig() + + raw = json.loads(Path(path).read_text(encoding="utf-8")) + attendance_raw = raw.get("attendance", {}) + payroll_raw = raw.get("payroll", {}) + employees_raw = payroll_raw.get("employees", {}) + + attendance = AttendanceRules( + on_work_time=attendance_raw.get("on_work_time", AttendanceRules.on_work_time), + off_work_time=attendance_raw.get("off_work_time", AttendanceRules.off_work_time), + overtime_round_minutes=int( + attendance_raw.get("overtime_round_minutes", AttendanceRules.overtime_round_minutes) + ), + standard_daily_hours=float( + attendance_raw.get("standard_daily_hours", AttendanceRules.standard_daily_hours) + ), + minor_late_minutes=int( + attendance_raw.get("minor_late_minutes", AttendanceRules.minor_late_minutes) + ), + minor_late_free_times=int( + attendance_raw.get("minor_late_free_times", AttendanceRules.minor_late_free_times) + ), + late_penalty=float(attendance_raw.get("late_penalty", AttendanceRules.late_penalty)), + missing_card_penalty=float( + attendance_raw.get("missing_card_penalty", AttendanceRules.missing_card_penalty) + ), + weekend_days=tuple(int(item) for item in attendance_raw.get("weekend_days", AttendanceRules.weekend_days)), + legal_holidays=tuple(attendance_raw.get("legal_holidays", AttendanceRules.legal_holidays)), + leave_keywords=tuple(attendance_raw.get("leave_keywords", AttendanceRules.leave_keywords)), + ) + + employees = { + name: EmployeePayConfig( + salary_mode=values.get("salary_mode", "monthly") or "monthly", + base_salary=_optional_float(values.get("base_salary")), + hourly_rate=float(values.get("hourly_rate", 0) or 0), + piece_quantity=float(values.get("piece_quantity", 0) or 0), + piece_unit_price=float(values.get("piece_unit_price", 0) or 0), + probation_type=values.get("probation_type", "ratio") or "ratio", + probation_ratio=float(values.get("probation_ratio", 1) or 1), + probation_salary=_optional_float(values.get("probation_salary")), + commission_amount=float(values.get("commission_amount", 0) or 0), + overtime_rate=_optional_float(values.get("overtime_rate")), + weekend_overtime_rate=_optional_float(values.get("weekend_overtime_rate")), + holiday_overtime_rate=_optional_float(values.get("holiday_overtime_rate")), + ) + for name, values in employees_raw.items() + } + + payroll = PayrollRules( + default_overtime_rate=float(payroll_raw.get("default_overtime_rate", 0)), + default_weekend_overtime_rate=float(payroll_raw.get("default_weekend_overtime_rate", 0)), + default_holiday_overtime_rate=float(payroll_raw.get("default_holiday_overtime_rate", 0)), + employees=employees, + ) + return AppConfig(attendance=attendance, payroll=payroll) + + +def _optional_float(value: object) -> float | None: + if value in (None, ""): + return None + return float(value) diff --git a/financial_system/core/logger.py b/financial_system/core/logger.py new file mode 100644 index 0000000..ca64411 --- /dev/null +++ b/financial_system/core/logger.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import logging +from logging.handlers import RotatingFileHandler +from pathlib import Path + +from .settings import AppSettings, get_settings + + +class _MaxLevelFilter(logging.Filter): + """只允许指定等级及以下日志进入 handler,用于拆分 info 和 error 文件。""" + + def __init__(self, max_level: int): + super().__init__() + self.max_level = max_level + + def filter(self, record: logging.LogRecord) -> bool: + return record.levelno <= self.max_level + + +class AppLogger: + """项目统一日志入口,负责初始化 handler 并返回模块 logger。""" + + _configured = False + _handler_marker = "_financial_system_handler" + + @classmethod + def configure(cls, settings: AppSettings | None = None) -> None: + settings = settings or get_settings() + log_level = getattr(logging, settings.log_level, logging.INFO) + settings.log_dir.mkdir(parents=True, exist_ok=True) + + handlers = cls._build_handlers(settings) + app_logger = logging.getLogger("financial_system") + app_logger.setLevel(log_level) + app_logger.propagate = False + cls._replace_handlers(app_logger, handlers) + + cls._configured = True + + @classmethod + def get_logger(cls, name: str) -> logging.Logger: + if not cls._configured: + cls.configure() + if not name.startswith("financial_system"): + name = f"financial_system.{name}" + return logging.getLogger(name) + + @classmethod + def _build_handlers(cls, settings: AppSettings) -> list[logging.Handler]: + formatter = logging.Formatter( + fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + info_handler = cls._file_handler( + settings.log_dir / settings.log_info_file, + logging.INFO, + settings, + ) + info_handler.addFilter(_MaxLevelFilter(logging.WARNING)) + info_handler.setFormatter(formatter) + + error_handler = cls._file_handler( + settings.log_dir / settings.log_error_file, + logging.ERROR, + settings, + ) + error_handler.setFormatter(formatter) + + handlers: list[logging.Handler] = [info_handler, error_handler] + if settings.log_console_enabled: + console_handler = logging.StreamHandler() + console_handler.setLevel(getattr(logging, settings.log_level, logging.INFO)) + console_handler.setFormatter(formatter) + setattr(console_handler, cls._handler_marker, True) + handlers.append(console_handler) + + for handler in handlers: + setattr(handler, cls._handler_marker, True) + return handlers + + @staticmethod + def _file_handler(path: Path, level: int, settings: AppSettings) -> RotatingFileHandler: + handler = RotatingFileHandler( + path, + maxBytes=settings.log_max_bytes, + backupCount=settings.log_backup_count, + encoding="utf-8", + ) + handler.setLevel(level) + return handler + + @classmethod + def _replace_handlers(cls, logger: logging.Logger, handlers: list[logging.Handler]) -> None: + for handler in list(logger.handlers): + if getattr(handler, cls._handler_marker, False): + logger.removeHandler(handler) + handler.close() + for handler in handlers: + logger.addHandler(handler) diff --git a/financial_system/core/permissions.py b/financial_system/core/permissions.py new file mode 100644 index 0000000..5d527b8 --- /dev/null +++ b/financial_system/core/permissions.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +ROLE_SUPERUSER = "superuser" +ROLE_MANAGER = "manager" +ROLE_VIEWER = "viewer" +VALID_ROLES = {ROLE_SUPERUSER, ROLE_MANAGER, ROLE_VIEWER} + +PERMISSION_PAYROLL_EXCEL_CALCULATE = "payroll:excel:calculate" +PERMISSION_PAYROLL_DINGTALK_CALCULATE = "payroll:dingtalk:calculate" +PERMISSION_PAYROLL_JOB_VIEW = "payroll:job:view" +PERMISSION_PAYROLL_FILE_DOWNLOAD = "payroll:file:download" +PERMISSION_USER_CREATE = "user:create" +PERMISSION_USER_LIST = "user:list" +PERMISSION_OPERATION_LOG_VIEW = "operation_log:view" +PERMISSION_EMPLOYEE_VIEW = "employee:view" +PERMISSION_EMPLOYEE_MANAGE = "employee:manage" +PERMISSION_ORGANIZATION_VIEW = "organization:view" +PERMISSION_ORGANIZATION_MANAGE = "organization:manage" +PERMISSION_SALARY_PROFILE_VIEW = "salary_profile:view" +PERMISSION_SALARY_PROFILE_MANAGE = "salary_profile:manage" +PERMISSION_COMMISSION_VIEW = "commission:view" +PERMISSION_COMMISSION_MANAGE = "commission:manage" +PERMISSION_CONFIG_VIEW = "config:view" +PERMISSION_CONFIG_MANAGE = "config:manage" +PERMISSION_REPORT_VIEW = "report:view" + + +@dataclass(frozen=True) +class MenuPermission: + """前端菜单权限描述。""" + + code: str + name: str + path: str + + +MENUS = { + "payroll": MenuPermission(code="payroll", name="工资计算", path="/payroll"), + "jobs": MenuPermission(code="jobs", name="计算记录", path="/payroll/jobs"), + "employees": MenuPermission(code="employees", name="员工维护", path="/system/employees"), + "organization": MenuPermission(code="organization", name="组织架构", path="/system/organization"), + "salary_profiles": MenuPermission(code="salary_profiles", name="薪资维护", path="/system/salary-profiles"), + "commissions": MenuPermission(code="commissions", name="提成管理", path="/payroll/commissions"), + "reports": MenuPermission(code="reports", name="统计报表", path="/reports"), + "configs": MenuPermission(code="configs", name="规则配置", path="/system/configs"), + "users": MenuPermission(code="users", name="用户管理", path="/system/users"), + "operation_logs": MenuPermission(code="operation_logs", name="操作日志", path="/system/operation-logs"), +} + +ROLE_MENU_CODES = { + ROLE_SUPERUSER: ( + "payroll", + "jobs", + "employees", + "organization", + "salary_profiles", + "commissions", + "reports", + "configs", + "users", + "operation_logs", + ), + ROLE_MANAGER: ( + "payroll", + "jobs", + "employees", + "organization", + "salary_profiles", + "commissions", + "reports", + "configs", + "operation_logs", + ), + ROLE_VIEWER: ("jobs", "reports"), +} + +ROLE_OPERATION_PERMISSIONS = { + ROLE_SUPERUSER: ( + PERMISSION_PAYROLL_EXCEL_CALCULATE, + PERMISSION_PAYROLL_DINGTALK_CALCULATE, + PERMISSION_PAYROLL_JOB_VIEW, + PERMISSION_PAYROLL_FILE_DOWNLOAD, + PERMISSION_USER_CREATE, + PERMISSION_USER_LIST, + PERMISSION_OPERATION_LOG_VIEW, + PERMISSION_EMPLOYEE_VIEW, + PERMISSION_EMPLOYEE_MANAGE, + PERMISSION_ORGANIZATION_VIEW, + PERMISSION_ORGANIZATION_MANAGE, + PERMISSION_SALARY_PROFILE_VIEW, + PERMISSION_SALARY_PROFILE_MANAGE, + PERMISSION_COMMISSION_VIEW, + PERMISSION_COMMISSION_MANAGE, + PERMISSION_CONFIG_VIEW, + PERMISSION_CONFIG_MANAGE, + PERMISSION_REPORT_VIEW, + ), + ROLE_MANAGER: ( + PERMISSION_PAYROLL_EXCEL_CALCULATE, + PERMISSION_PAYROLL_DINGTALK_CALCULATE, + PERMISSION_PAYROLL_JOB_VIEW, + PERMISSION_PAYROLL_FILE_DOWNLOAD, + PERMISSION_OPERATION_LOG_VIEW, + PERMISSION_EMPLOYEE_VIEW, + PERMISSION_EMPLOYEE_MANAGE, + PERMISSION_ORGANIZATION_VIEW, + PERMISSION_ORGANIZATION_MANAGE, + PERMISSION_SALARY_PROFILE_VIEW, + PERMISSION_SALARY_PROFILE_MANAGE, + PERMISSION_COMMISSION_VIEW, + PERMISSION_COMMISSION_MANAGE, + PERMISSION_CONFIG_VIEW, + PERMISSION_CONFIG_MANAGE, + PERMISSION_REPORT_VIEW, + ), + ROLE_VIEWER: ( + PERMISSION_PAYROLL_JOB_VIEW, + PERMISSION_PAYROLL_FILE_DOWNLOAD, + PERMISSION_REPORT_VIEW, + ), +} + + +def menus_for_role(role: str) -> list[MenuPermission]: + """返回角色可见菜单。""" + return [MENUS[code] for code in ROLE_MENU_CODES.get(role, ())] + + +def permissions_for_role(role: str) -> list[str]: + """返回角色可执行的操作权限码。""" + return list(ROLE_OPERATION_PERMISSIONS.get(role, ())) + + +def has_permission(role: str, permission: str) -> bool: + """路由鉴权使用的权限判断。""" + return permission in ROLE_OPERATION_PERMISSIONS.get(role, ()) diff --git a/financial_system/core/security.py b/financial_system/core/security.py new file mode 100644 index 0000000..97b59f1 --- /dev/null +++ b/financial_system/core/security.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import os +import time + + +HASH_NAME = "sha256" +HASH_ITERATIONS = 260_000 + + +class TokenError(ValueError): + pass + + +def hash_password(password: str) -> str: + """使用 PBKDF2 保存密码摘要,数据库永远不存明文密码。""" + salt = os.urandom(16) + digest = hashlib.pbkdf2_hmac(HASH_NAME, password.encode("utf-8"), salt, HASH_ITERATIONS) + return "$".join( + [ + "pbkdf2_sha256", + str(HASH_ITERATIONS), + _b64encode(salt), + _b64encode(digest), + ] + ) + + +def verify_password(password: str, password_hash: str) -> bool: + """用常量时间比较校验密码,降低时序侧信道风险。""" + try: + algorithm, iterations_text, salt_text, digest_text = password_hash.split("$", 3) + except ValueError: + return False + if algorithm != "pbkdf2_sha256": + return False + salt = _b64decode(salt_text) + expected = _b64decode(digest_text) + actual = hashlib.pbkdf2_hmac( + HASH_NAME, + password.encode("utf-8"), + salt, + int(iterations_text), + ) + return hmac.compare_digest(actual, expected) + + +def create_access_token( + *, + user_id: int, + username: str, + role: str, + secret_key: str, + expires_minutes: int, +) -> str: + """生成轻量 HMAC token,payload 明文可读但不可被客户端篡改。""" + payload = { + "sub": str(user_id), + "username": username, + "role": role, + "exp": int(time.time()) + expires_minutes * 60, + } + payload_text = _b64encode(json.dumps(payload, separators=(",", ":")).encode("utf-8")) + signature = _sign(payload_text, secret_key) + return f"{payload_text}.{signature}" + + +def decode_access_token(token: str, secret_key: str) -> dict: + """校验 token 签名和过期时间,失败时抛出 TokenError。""" + try: + payload_text, signature = token.split(".", 1) + except ValueError as exc: + raise TokenError("无效 token") from exc + expected_signature = _sign(payload_text, secret_key) + if not hmac.compare_digest(signature, expected_signature): + raise TokenError("无效 token 签名") + payload = json.loads(_b64decode(payload_text)) + if int(payload.get("exp", 0)) < int(time.time()): + raise TokenError("登录已过期") + return payload + + +def _sign(payload_text: str, secret_key: str) -> str: + digest = hmac.new(secret_key.encode("utf-8"), payload_text.encode("utf-8"), hashlib.sha256).digest() + return _b64encode(digest) + + +def _b64encode(value: bytes) -> str: + return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii") + + +def _b64decode(value: str) -> bytes: + padding = "=" * (-len(value) % 4) + return base64.urlsafe_b64decode(value + padding) diff --git a/financial_system/core/settings.py b/financial_system/core/settings.py new file mode 100644 index 0000000..c44a795 --- /dev/null +++ b/financial_system/core/settings.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +DEFAULT_CONFIG_PATH = PROJECT_ROOT / "config" / "app_settings.json" +DEFAULT_DATABASE_URL = "mysql+pymysql://root:12345678@localhost:3306/financial_system?charset=utf8mb4" +DEFAULT_CORS_ALLOWED_ORIGINS = ( + "http://127.0.0.1:5173", + "http://localhost:5173", + "http://127.0.0.1:5174", + "http://localhost:5174", +) + + +@dataclass(frozen=True) +class AppSettings: + """运行时配置,统一来自 config/app_settings.json。""" + + project_root: Path = PROJECT_ROOT + config_path: Path = DEFAULT_CONFIG_PATH + server_host: str = "0.0.0.0" + server_port: int = 8000 + server_fallback_port: int = 8001 + log_dir: Path = PROJECT_ROOT / "logs" + log_level: str = "INFO" + log_info_file: str = "info.log" + log_error_file: str = "error.log" + log_max_bytes: int = 10 * 1024 * 1024 + log_backup_count: int = 10 + log_console_enabled: bool = True + upload_dir: Path = PROJECT_ROOT / "uploads" + output_dir: Path = PROJECT_ROOT / "outputs" + avatar_dir: Path = PROJECT_ROOT / "static" / "uploads" / "avatars" + database_url: str = DEFAULT_DATABASE_URL + dingtalk_app_key: str = "" + dingtalk_app_secret: str = "" + dingtalk_base_url: str = "https://oapi.dingtalk.com" + dingtalk_timezone: str = "Asia/Shanghai" + auth_secret_key: str = "financial-system-dev-secret" + access_token_expire_minutes: int = 480 + cors_allowed_origins: tuple[str, ...] = DEFAULT_CORS_ALLOWED_ORIGINS + bootstrap_superuser_username: str = "admin" + bootstrap_superuser_password: str = "Admin@123456" + bootstrap_superuser_display_name: str = "超级管理员" + + +def get_settings() -> AppSettings: + raw = _load_settings_file() + server = _section(raw, "server") + logging_config = _section(raw, "logging") + storage = _section(raw, "storage") + database = _section(raw, "database") + dingtalk = _section(raw, "dingtalk") + auth = _section(raw, "auth") + bootstrap_superuser = _section(raw, "bootstrap_superuser") + cors = _section(raw, "cors") + + return AppSettings( + config_path=_resolve_config_path(), + server_host=str(server.get("host", "0.0.0.0")), + server_port=int(server.get("port", 8000)), + server_fallback_port=int(server.get("fallback_port", 8001)), + log_dir=_project_path(logging_config.get("dir", "logs")), + log_level=str(logging_config.get("level", "INFO")).upper(), + log_info_file=str(logging_config.get("info_file", "info.log")), + log_error_file=str(logging_config.get("error_file", "error.log")), + log_max_bytes=int(logging_config.get("max_bytes", 10 * 1024 * 1024)), + log_backup_count=int(logging_config.get("backup_count", 10)), + log_console_enabled=_bool(logging_config.get("console_enabled", True)), + upload_dir=_project_path(storage.get("upload_dir", "uploads")), + output_dir=_project_path(storage.get("output_dir", "outputs")), + avatar_dir=_project_path(storage.get("avatar_dir", "static/uploads/avatars")), + database_url=str(database.get("url", DEFAULT_DATABASE_URL)).strip(), + dingtalk_app_key=str(dingtalk.get("app_key", "")).strip(), + dingtalk_app_secret=str(dingtalk.get("app_secret", "")).strip(), + dingtalk_base_url=str(dingtalk.get("base_url", "https://oapi.dingtalk.com")).rstrip("/"), + dingtalk_timezone=str(dingtalk.get("timezone", "Asia/Shanghai")), + auth_secret_key=str(auth.get("secret_key", "financial-system-dev-secret")), + access_token_expire_minutes=int(auth.get("access_token_expire_minutes", 480)), + cors_allowed_origins=_string_tuple( + cors.get("allowed_origins", DEFAULT_CORS_ALLOWED_ORIGINS), + DEFAULT_CORS_ALLOWED_ORIGINS, + ), + bootstrap_superuser_username=str(bootstrap_superuser.get("username", "admin")), + bootstrap_superuser_password=str(bootstrap_superuser.get("password", "Admin@123456")), + bootstrap_superuser_display_name=str(bootstrap_superuser.get("display_name", "超级管理员")), + ) + + +def _load_settings_file() -> dict[str, Any]: + config_path = _resolve_config_path() + if not config_path.exists(): + if os.getenv("APP_CONFIG_FILE"): + raise FileNotFoundError(f"配置文件不存在:{config_path}") + return {} + + raw = json.loads(config_path.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + raise ValueError(f"配置文件根节点必须是 JSON 对象:{config_path}") + return raw + + +def _resolve_config_path() -> Path: + configured_path = os.getenv("APP_CONFIG_FILE", "").strip() + if not configured_path: + return DEFAULT_CONFIG_PATH + + path = Path(configured_path).expanduser() + return path if path.is_absolute() else PROJECT_ROOT / path + + +def _section(raw: dict[str, Any], name: str) -> dict[str, Any]: + value = raw.get(name, {}) + if not isinstance(value, dict): + raise ValueError(f"配置项 {name} 必须是 JSON 对象") + return value + + +def _project_path(value: object) -> Path: + path = Path(str(value)).expanduser() + return path if path.is_absolute() else PROJECT_ROOT / path + + +def _string_tuple(value: object, default: tuple[str, ...]) -> tuple[str, ...]: + if value is None: + return default + if isinstance(value, str): + items = value.split(",") + elif isinstance(value, list | tuple): + items = value + else: + raise ValueError("字符串列表配置必须是数组或逗号分隔字符串") + + normalized = tuple(str(item).strip() for item in items if str(item).strip()) + return normalized or default + + +def _bool(value: object) -> bool: + if isinstance(value, bool): + return value + return str(value).strip().lower() in {"1", "true", "yes", "y", "on"} diff --git a/financial_system/database/__init__.py b/financial_system/database/__init__.py new file mode 100644 index 0000000..4ca7fbb --- /dev/null +++ b/financial_system/database/__init__.py @@ -0,0 +1,3 @@ +from .session import SessionLocal, init_database + +__all__ = ["SessionLocal", "init_database"] diff --git a/financial_system/database/orm.py b/financial_system/database/orm.py new file mode 100644 index 0000000..6620878 --- /dev/null +++ b/financial_system/database/orm.py @@ -0,0 +1,871 @@ +from __future__ import annotations + +from datetime import date, datetime + +from sqlalchemy import Boolean, Date, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + + +class Base(DeclarativeBase): + pass + + +class UserORM(Base): + """系统登录用户,role 决定菜单和操作权限。""" + + __tablename__ = "users" + __table_args__ = { + "comment": "系统用户表,保存登录账号、角色和启用状态", + "mysql_charset": "utf8mb4", + "mysql_collate": "utf8mb4_unicode_ci", + } + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="用户主键ID") + username: Mapped[str] = mapped_column( + String(64), + unique=True, + nullable=False, + index=True, + comment="登录用户名,全局唯一", + ) + display_name: Mapped[str] = mapped_column( + String(128), + nullable=False, + default="", + comment="用户显示名称", + ) + avatar_url: Mapped[str] = mapped_column( + String(512), + nullable=False, + default="", + comment="用户头像URL或静态资源路径", + ) + email: Mapped[str] = mapped_column( + String(128), + nullable=False, + default="", + comment="用户邮箱", + ) + phone: Mapped[str] = mapped_column( + String(32), + nullable=False, + default="", + comment="用户手机号", + ) + department: Mapped[str] = mapped_column( + String(128), + nullable=False, + default="", + comment="用户所属部门", + ) + position_title: Mapped[str] = mapped_column( + String(128), + nullable=False, + default="", + comment="用户职位或岗位名称", + ) + password_hash: Mapped[str] = mapped_column( + String(256), + nullable=False, + comment="密码哈希值,不保存明文密码", + ) + role: Mapped[str] = mapped_column( + String(32), + nullable=False, + index=True, + comment="用户角色:superuser、manager、viewer", + ) + is_active: Mapped[bool] = mapped_column( + Boolean, + nullable=False, + default=True, + comment="是否启用账号", + ) + created_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + default=datetime.utcnow, + comment="创建时间", + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + default=datetime.utcnow, + onupdate=datetime.utcnow, + comment="更新时间", + ) + + +class DepartmentORM(Base): + """组织部门基础数据,支持父子级部门结构。""" + + __tablename__ = "departments" + __table_args__ = { + "comment": "部门表,维护组织架构中的部门及上下级关系", + "mysql_charset": "utf8mb4", + "mysql_collate": "utf8mb4_unicode_ci", + } + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="部门主键ID") + parent_id: Mapped[int | None] = mapped_column( + ForeignKey("departments.id", ondelete="SET NULL"), + nullable=True, + index=True, + comment="父级部门ID,顶级部门为空", + ) + department_code: Mapped[str] = mapped_column( + String(64), + unique=True, + nullable=False, + index=True, + comment="部门编码,全局唯一", + ) + name: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True, comment="部门名称") + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="排序号,越小越靠前") + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True, comment="是否启用") + remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="部门备注") + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间") + updated_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + default=datetime.utcnow, + onupdate=datetime.utcnow, + comment="更新时间", + ) + + +class PositionORM(Base): + """岗位基础数据,岗位隶属于一个部门。""" + + __tablename__ = "positions" + __table_args__ = ( + UniqueConstraint("department_id", "name", name="uq_positions_department_name"), + { + "comment": "岗位表,维护部门下的职位/岗位", + "mysql_charset": "utf8mb4", + "mysql_collate": "utf8mb4_unicode_ci", + }, + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="岗位主键ID") + department_id: Mapped[int] = mapped_column( + ForeignKey("departments.id", ondelete="CASCADE"), + nullable=False, + index=True, + comment="关联部门ID", + ) + position_code: Mapped[str] = mapped_column( + String(64), + unique=True, + nullable=False, + index=True, + comment="岗位编码,全局唯一", + ) + name: Mapped[str] = mapped_column(String(128), nullable=False, index=True, comment="岗位名称") + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="排序号,越小越靠前") + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True, comment="是否启用") + remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="岗位备注") + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间") + updated_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + default=datetime.utcnow, + onupdate=datetime.utcnow, + comment="更新时间", + ) + + department: Mapped[DepartmentORM] = relationship() + + +class EmployeeORM(Base): + """员工基础档案,用于工资计算和薪资档案关联。""" + + __tablename__ = "employees" + __table_args__ = { + "comment": "员工基础档案表,维护员工编号、姓名、部门、岗位和钉钉用户ID", + "mysql_charset": "utf8mb4", + "mysql_collate": "utf8mb4_unicode_ci", + } + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="员工主键ID") + employee_no: Mapped[str] = mapped_column( + String(64), + unique=True, + nullable=False, + index=True, + comment="员工编号,全局唯一,系统自动生成,默认ZA前缀", + ) + name: Mapped[str] = mapped_column(String(128), nullable=False, index=True, comment="员工姓名") + dingtalk_user_id: Mapped[str] = mapped_column( + String(128), + nullable=False, + default="", + index=True, + comment="钉钉用户ID,用于对接钉钉考勤", + ) + department: Mapped[str] = mapped_column(String(128), nullable=False, default="", comment="部门名称") + position: Mapped[str] = mapped_column(String(128), nullable=False, default="", comment="岗位名称") + phone: Mapped[str] = mapped_column(String(32), nullable=False, default="", comment="手机号") + employment_status: Mapped[str] = mapped_column( + String(32), + nullable=False, + default="active", + index=True, + comment="员工状态:active在职、inactive离职", + ) + remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="员工备注") + created_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + default=datetime.utcnow, + comment="创建时间", + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + default=datetime.utcnow, + onupdate=datetime.utcnow, + comment="更新时间", + ) + + salary_profile: Mapped["SalaryProfileORM | None"] = relationship( + back_populates="employee", + cascade="all, delete-orphan", + uselist=False, + ) + + +class SalaryProfileORM(Base): + """员工薪资档案,独立维护薪资模式、底薪、计时/计件规则和加班费单价。""" + + __tablename__ = "salary_profiles" + __table_args__ = { + "comment": "员工薪资档案表,维护薪资模式、底薪、计时计件规则、默认提成和加班费单价", + "mysql_charset": "utf8mb4", + "mysql_collate": "utf8mb4_unicode_ci", + } + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="薪资档案主键ID") + employee_id: Mapped[int] = mapped_column( + ForeignKey("employees.id", ondelete="CASCADE"), + unique=True, + nullable=False, + index=True, + comment="关联员工ID,一名员工一条当前薪资档案", + ) + salary_mode: Mapped[str] = mapped_column( + String(32), + nullable=False, + default="monthly", + index=True, + comment="薪资模式:monthly包月、hourly计时、piecework计件、probation试用期", + ) + base_salary: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="基础工资或底薪") + hourly_rate: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="计时工资小时单价") + piece_quantity: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="计件数量") + piece_unit_price: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="计件单价") + probation_type: Mapped[str] = mapped_column( + String(32), + nullable=False, + default="ratio", + comment="试用期计算方式:ratio固定比例、fixed固定金额", + ) + probation_ratio: Mapped[float] = mapped_column(Float, nullable=False, default=1, comment="试用期工资比例") + probation_salary: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="试用期固定工资") + commission_amount: Mapped[float] = mapped_column( + Float, + nullable=False, + default=0, + comment="默认提成金额,月度提成优先维护到commission_records", + ) + overtime_rate: Mapped[float] = mapped_column( + Float, + nullable=False, + default=0, + comment="工作日加班费小时单价,独立于基础工资维护", + ) + weekend_overtime_rate: Mapped[float] = mapped_column( + Float, + nullable=False, + default=0, + comment="周末加班费小时单价", + ) + holiday_overtime_rate: Mapped[float] = mapped_column( + Float, + nullable=False, + default=0, + comment="法定节假日加班费小时单价", + ) + effective_month: Mapped[str] = mapped_column( + String(7), + nullable=False, + default="", + comment="生效月份,格式YYYY-MM;为空表示长期当前档案", + ) + remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="薪资备注") + created_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + default=datetime.utcnow, + comment="创建时间", + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + default=datetime.utcnow, + onupdate=datetime.utcnow, + comment="更新时间", + ) + + employee: Mapped[EmployeeORM] = relationship(back_populates="salary_profile") + + +class AttendanceRecordORM(Base): + """标准化后的每日考勤记录,来自 Excel 或钉钉接口。""" + + __tablename__ = "attendance_record" + __table_args__ = { + "comment": "考勤记录表,保存标准化后的每日打卡、迟到、缺卡、请假和加班工时", + "mysql_charset": "utf8mb4", + "mysql_collate": "utf8mb4_unicode_ci", + } + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="考勤记录主键ID") + job_id: Mapped[str | None] = mapped_column( + ForeignKey("payroll_jobs.id", ondelete="SET NULL"), + nullable=True, + index=True, + comment="关联工资计算任务ID", + ) + employee_id: Mapped[int | None] = mapped_column( + ForeignKey("employees.id", ondelete="SET NULL"), + nullable=True, + index=True, + comment="关联员工ID,未匹配员工时为空", + ) + employee_no: Mapped[str] = mapped_column(String(64), nullable=False, default="", index=True, comment="员工编号快照") + employee_name: Mapped[str] = mapped_column(String(128), nullable=False, index=True, comment="员工姓名快照") + department: Mapped[str] = mapped_column(String(128), nullable=False, default="", comment="部门名称快照") + work_date: Mapped[date] = mapped_column(Date, nullable=False, index=True, comment="考勤日期") + first_punch: Mapped[str] = mapped_column(String(5), nullable=False, default="", comment="上班打卡时间HH:MM") + last_punch: Mapped[str] = mapped_column(String(5), nullable=False, default="", comment="下班打卡时间HH:MM") + attendance_status: Mapped[str] = mapped_column( + String(32), + nullable=False, + default="present", + index=True, + comment="考勤状态:present出勤、absent缺勤、leave请假、missing缺卡", + ) + late_minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="当日迟到分钟合计") + missing_card_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="当日缺卡次数") + leave_hours: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="当日请假或调休工时") + overtime_hours: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="当日打卡推算加班工时") + raw_text: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="原始考勤单元格内容") + source_type: Mapped[str] = mapped_column(String(32), nullable=False, default="excel", comment="来源:excel或dingtalk") + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间") + + +class LeaveRecordORM(Base): + """请假/调休记录,统一折算为工时。""" + + __tablename__ = "leave_record" + __table_args__ = { + "comment": "请假记录表,保存事假、病假、年假、调休等统一工时记录", + "mysql_charset": "utf8mb4", + "mysql_collate": "utf8mb4_unicode_ci", + } + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="请假记录主键ID") + job_id: Mapped[str | None] = mapped_column( + ForeignKey("payroll_jobs.id", ondelete="SET NULL"), + nullable=True, + index=True, + comment="关联工资计算任务ID", + ) + employee_id: Mapped[int | None] = mapped_column( + ForeignKey("employees.id", ondelete="SET NULL"), + nullable=True, + index=True, + comment="关联员工ID,未匹配员工时为空", + ) + employee_no: Mapped[str] = mapped_column(String(64), nullable=False, default="", index=True, comment="员工编号快照") + employee_name: Mapped[str] = mapped_column(String(128), nullable=False, index=True, comment="员工姓名快照") + leave_type: Mapped[str] = mapped_column(String(32), nullable=False, index=True, comment="请假类型") + start_text: Mapped[str] = mapped_column(String(32), nullable=False, default="", comment="原始开始时间文本") + end_text: Mapped[str] = mapped_column(String(32), nullable=False, default="", comment="原始结束时间文本") + hours: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="请假工时") + source_type: Mapped[str] = mapped_column(String(32), nullable=False, default="excel", comment="来源:excel或dingtalk") + status: Mapped[str] = mapped_column(String(32), nullable=False, default="approved", comment="状态:approved已生效") + remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="备注") + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间") + + +class OvertimeRecordORM(Base): + """标准化后的加班记录,按工作日、周末、节假日拆分。""" + + __tablename__ = "overtime_record" + __table_args__ = { + "comment": "加班记录表,保存每日加班工时、加班类型、单价和金额", + "mysql_charset": "utf8mb4", + "mysql_collate": "utf8mb4_unicode_ci", + } + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="加班记录主键ID") + job_id: Mapped[str | None] = mapped_column( + ForeignKey("payroll_jobs.id", ondelete="SET NULL"), + nullable=True, + index=True, + comment="关联工资计算任务ID", + ) + employee_id: Mapped[int | None] = mapped_column( + ForeignKey("employees.id", ondelete="SET NULL"), + nullable=True, + index=True, + comment="关联员工ID,未匹配员工时为空", + ) + employee_no: Mapped[str] = mapped_column(String(64), nullable=False, default="", index=True, comment="员工编号快照") + employee_name: Mapped[str] = mapped_column(String(128), nullable=False, index=True, comment="员工姓名快照") + work_date: Mapped[date] = mapped_column(Date, nullable=False, index=True, comment="加班日期") + overtime_type: Mapped[str] = mapped_column( + String(32), + nullable=False, + default="workday", + index=True, + comment="加班类型:workday工作日、weekend周末、holiday法定节假日", + ) + hours: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="加班工时") + rate: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="加班小时单价") + amount: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="加班费金额") + source_type: Mapped[str] = mapped_column(String(32), nullable=False, default="excel", comment="来源:excel或dingtalk") + status: Mapped[str] = mapped_column(String(32), nullable=False, default="calculated", comment="状态:calculated已计算") + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间") + + +class CommissionRecordORM(Base): + """月度提成或奖金记录,支持手工维护和 Excel 导入。""" + + __tablename__ = "commission_record" + __table_args__ = { + "comment": "提成记录表,保存销售提成、项目奖金、绩效奖励等月度金额", + "mysql_charset": "utf8mb4", + "mysql_collate": "utf8mb4_unicode_ci", + } + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="提成记录主键ID") + employee_id: Mapped[int] = mapped_column( + ForeignKey("employees.id", ondelete="CASCADE"), + nullable=False, + index=True, + comment="关联员工ID", + ) + commission_month: Mapped[str] = mapped_column(String(7), nullable=False, index=True, comment="提成月份,格式YYYY-MM") + commission_type: Mapped[str] = mapped_column(String(64), nullable=False, default="销售提成", comment="提成类型") + amount: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="提成金额") + source_type: Mapped[str] = mapped_column(String(32), nullable=False, default="manual", comment="来源:manual手工、excel导入") + business_ref: Mapped[str] = mapped_column(String(128), nullable=False, default="", comment="业务单号或来源标识") + remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="备注") + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间") + updated_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + default=datetime.utcnow, + onupdate=datetime.utcnow, + comment="更新时间", + ) + + employee: Mapped[EmployeeORM] = relationship() + + +class OperationLogORM(Base): + """系统操作日志,用于审计用户在后台执行的关键动作。""" + + __tablename__ = "operation_logs" + __table_args__ = { + "comment": "系统操作日志表,记录用户登录、资料维护、工资计算、下载和系统管理动作", + "mysql_charset": "utf8mb4", + "mysql_collate": "utf8mb4_unicode_ci", + } + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="日志主键ID") + user_id: Mapped[int | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + index=True, + comment="操作用户ID,匿名或用户删除后为空", + ) + username: Mapped[str] = mapped_column( + String(64), + nullable=False, + default="", + index=True, + comment="操作用户名快照", + ) + role: Mapped[str] = mapped_column( + String(32), + nullable=False, + default="", + comment="操作用户角色快照", + ) + module: Mapped[str] = mapped_column( + String(64), + nullable=False, + index=True, + comment="业务模块,例如auth、payroll、system", + ) + action: Mapped[str] = mapped_column( + String(128), + nullable=False, + index=True, + comment="操作动作编码,例如login、payroll.excel.calculate", + ) + target_type: Mapped[str] = mapped_column( + String(64), + nullable=False, + default="", + comment="操作对象类型,例如user、payroll_job、file", + ) + target_id: Mapped[str] = mapped_column( + String(128), + nullable=False, + default="", + comment="操作对象ID或业务标识", + ) + status: Mapped[str] = mapped_column( + String(32), + nullable=False, + index=True, + comment="操作状态:success或failed", + ) + detail: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="操作详情或失败原因") + ip_address: Mapped[str] = mapped_column( + String(64), + nullable=False, + default="", + comment="客户端IP地址", + ) + user_agent: Mapped[str] = mapped_column( + String(512), + nullable=False, + default="", + comment="客户端浏览器User-Agent", + ) + created_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + default=datetime.utcnow, + index=True, + comment="操作时间", + ) + + user: Mapped[UserORM | None] = relationship() + + +class PayrollJobORM(Base): + """一次工资计算任务,记录来源、状态、输入输出文件。""" + + __tablename__ = "payroll_jobs" + __table_args__ = { + "comment": "工资计算任务表,记录每次Excel或钉钉实时计算任务", + "mysql_charset": "utf8mb4", + "mysql_collate": "utf8mb4_unicode_ci", + } + + id: Mapped[str] = mapped_column(String(32), primary_key=True, comment="任务ID,UUID十六进制字符串") + source_type: Mapped[str] = mapped_column( + String(32), + nullable=False, + comment="任务来源:excel或dingtalk", + ) + status: Mapped[str] = mapped_column( + String(32), + nullable=False, + default="created", + comment="任务状态:running、completed、failed", + ) + input_file: Mapped[str | None] = mapped_column(Text, comment="上传的原始Excel文件路径") + output_file: Mapped[str | None] = mapped_column(Text, comment="导出的工资结果Excel文件路径") + employee_count: Mapped[int] = mapped_column( + Integer, + nullable=False, + default=0, + comment="本次任务计算的员工数量", + ) + error_message: Mapped[str | None] = mapped_column(Text, comment="任务失败时的错误信息") + created_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + default=datetime.utcnow, + comment="创建时间", + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + default=datetime.utcnow, + onupdate=datetime.utcnow, + comment="更新时间", + ) + + results: Mapped[list["PayrollResultORM"]] = relationship( + back_populates="job", + cascade="all, delete-orphan", + ) + + +class PayrollResultORM(Base): + """员工级工资计算结果,便于后续查询和审计。""" + + __tablename__ = "payroll_results" + __table_args__ = { + "comment": "员工工资计算结果表,保存任务下每个员工的加班、请假、扣款和工资结果", + "mysql_charset": "utf8mb4", + "mysql_collate": "utf8mb4_unicode_ci", + } + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="结果主键ID") + job_id: Mapped[str] = mapped_column( + ForeignKey("payroll_jobs.id"), + nullable=False, + index=True, + comment="所属工资计算任务ID", + ) + name: Mapped[str] = mapped_column(String(128), nullable=False, comment="员工姓名") + department: Mapped[str] = mapped_column(String(128), nullable=False, default="", comment="部门名称") + position: Mapped[str] = mapped_column(String(128), nullable=False, default="", comment="岗位名称") + attendance_group: Mapped[str] = mapped_column( + String(128), + nullable=False, + default="", + comment="考勤组名称", + ) + salary_month: Mapped[str] = mapped_column(String(7), nullable=False, default="", index=True, comment="工资月份,格式YYYY-MM") + salary_mode: Mapped[str] = mapped_column(String(32), nullable=False, default="monthly", comment="薪资模式快照") + attendance_days: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="出勤天数") + absence_days: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="缺勤天数") + actual_work_hours: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="实际工作工时") + punch_overtime_hours: Mapped[float] = mapped_column( + Float, + nullable=False, + default=0, + comment="根据下班打卡时间推算的加班工时", + ) + leave_hours: Mapped[float] = mapped_column( + Float, + nullable=False, + default=0, + comment="请假或调休工时", + ) + remaining_overtime_hours: Mapped[float] = mapped_column( + Float, + nullable=False, + default=0, + comment="剩余加班工时:打卡推算加班工时减请假调休工时", + ) + workday_overtime_hours: Mapped[float] = mapped_column( + Float, + nullable=False, + default=0, + comment="工作日剩余可计费加班工时", + ) + weekend_overtime_hours: Mapped[float] = mapped_column( + Float, + nullable=False, + default=0, + comment="周末剩余可计费加班工时", + ) + holiday_overtime_hours: Mapped[float] = mapped_column( + Float, + nullable=False, + default=0, + comment="法定节假日剩余可计费加班工时", + ) + minor_late_count: Mapped[int] = mapped_column( + Integer, + nullable=False, + default=0, + comment="5分钟内迟到次数", + ) + major_late_count: Mapped[int] = mapped_column( + Integer, + nullable=False, + default=0, + comment="超过5分钟迟到次数", + ) + penalized_late_count: Mapped[int] = mapped_column( + Integer, + nullable=False, + default=0, + comment="实际计扣迟到次数", + ) + late_deduction: Mapped[float] = mapped_column( + Float, + nullable=False, + default=0, + comment="迟到扣款金额", + ) + missing_card_count: Mapped[int] = mapped_column( + Integer, + nullable=False, + default=0, + comment="缺卡次数", + ) + missing_card_deduction: Mapped[float] = mapped_column( + Float, + nullable=False, + default=0, + comment="缺卡扣款金额", + ) + total_deduction: Mapped[float] = mapped_column( + Float, + nullable=False, + default=0, + comment="扣款合计金额", + ) + base_salary: Mapped[float | None] = mapped_column(Float, comment="员工底薪") + base_pay: Mapped[float | None] = mapped_column(Float, comment="按薪资模式计算后的基础工资") + commission_amount: Mapped[float] = mapped_column( + Float, + nullable=False, + default=0, + comment="手工维护的提成费用", + ) + overtime_rate: Mapped[float] = mapped_column( + Float, + nullable=False, + default=0, + comment="加班小时单价", + ) + weekend_overtime_rate: Mapped[float] = mapped_column( + Float, + nullable=False, + default=0, + comment="周末加班小时单价", + ) + holiday_overtime_rate: Mapped[float] = mapped_column( + Float, + nullable=False, + default=0, + comment="法定节假日加班小时单价", + ) + overtime_pay: Mapped[float] = mapped_column( + Float, + nullable=False, + default=0, + comment="加班费金额", + ) + gross_salary: Mapped[float | None] = mapped_column(Float, comment="应发工资") + net_salary: Mapped[float | None] = mapped_column(Float, comment="实发工资") + note: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="备注") + + job: Mapped[PayrollJobORM] = relationship(back_populates="results") + + +class SalaryRecordORM(Base): + """月度工资汇总记录,用于工资条、月报表和财务报表查询。""" + + __tablename__ = "salary_record" + __table_args__ = { + "comment": "工资记录表,保存员工月度工资汇总结果", + "mysql_charset": "utf8mb4", + "mysql_collate": "utf8mb4_unicode_ci", + } + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="工资记录主键ID") + job_id: Mapped[str | None] = mapped_column( + ForeignKey("payroll_jobs.id", ondelete="SET NULL"), + nullable=True, + index=True, + comment="关联工资计算任务ID", + ) + employee_id: Mapped[int | None] = mapped_column( + ForeignKey("employees.id", ondelete="SET NULL"), + nullable=True, + index=True, + comment="关联员工ID,未匹配员工时为空", + ) + employee_no: Mapped[str] = mapped_column(String(64), nullable=False, default="", index=True, comment="员工编号快照") + employee_name: Mapped[str] = mapped_column(String(128), nullable=False, index=True, comment="员工姓名快照") + department: Mapped[str] = mapped_column(String(128), nullable=False, default="", index=True, comment="部门名称快照") + salary_month: Mapped[str] = mapped_column(String(7), nullable=False, index=True, comment="工资月份,格式YYYY-MM") + salary_mode: Mapped[str] = mapped_column(String(32), nullable=False, default="monthly", comment="薪资模式快照") + base_pay: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="基础工资金额") + commission_amount: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="提成金额") + overtime_pay: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="加班费金额") + deduction_amount: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="扣款合计金额") + gross_salary: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="应发工资") + net_salary: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="实发工资") + status: Mapped[str] = mapped_column(String(32), nullable=False, default="calculated", comment="状态:calculated已计算") + remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="备注") + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间") + updated_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + default=datetime.utcnow, + onupdate=datetime.utcnow, + comment="更新时间", + ) + + details: Mapped[list["SalaryDetailORM"]] = relationship( + back_populates="salary_record", + cascade="all, delete-orphan", + ) + + +class SalaryDetailORM(Base): + """工资明细记录,按工资项拆分基础工资、提成、加班费和扣款。""" + + __tablename__ = "salary_detail" + __table_args__ = { + "comment": "工资明细表,保存工资记录下的每个工资项", + "mysql_charset": "utf8mb4", + "mysql_collate": "utf8mb4_unicode_ci", + } + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="工资明细主键ID") + salary_record_id: Mapped[int] = mapped_column( + ForeignKey("salary_record.id", ondelete="CASCADE"), + nullable=False, + index=True, + comment="关联工资记录ID", + ) + item_type: Mapped[str] = mapped_column( + String(32), + nullable=False, + index=True, + comment="工资项类型:earning收入、deduction扣款、stat统计", + ) + item_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="工资项名称") + amount: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="金额") + quantity: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="数量或工时") + unit_price: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="单价") + remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="备注") + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间") + + salary_record: Mapped[SalaryRecordORM] = relationship(back_populates="details") + + +class SalaryConfigORM(Base): + """系统配置中心,保存考勤规则、薪资规则和后续集成参数。""" + + __tablename__ = "salary_config" + __table_args__ = { + "comment": "系统配置表,保存可后台维护的考勤、薪资和集成规则", + "mysql_charset": "utf8mb4", + "mysql_collate": "utf8mb4_unicode_ci", + } + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="配置主键ID") + config_key: Mapped[str] = mapped_column(String(128), nullable=False, unique=True, index=True, comment="配置键") + config_name: Mapped[str] = mapped_column(String(128), nullable=False, comment="配置名称") + config_group: Mapped[str] = mapped_column(String(64), nullable=False, index=True, comment="配置分组") + config_value: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="配置值,按value_type解析") + value_type: Mapped[str] = mapped_column(String(32), nullable=False, default="string", comment="值类型:string、number、integer、boolean、json") + is_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, comment="是否启用") + remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="配置说明") + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间") + updated_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + default=datetime.utcnow, + onupdate=datetime.utcnow, + comment="更新时间", + ) diff --git a/financial_system/database/session.py b/financial_system/database/session.py new file mode 100644 index 0000000..0893c22 --- /dev/null +++ b/financial_system/database/session.py @@ -0,0 +1,239 @@ +from __future__ import annotations + +from sqlalchemy import create_engine, inspect, text +from sqlalchemy.engine import make_url +from sqlalchemy.orm import sessionmaker + +from ..core.logger import AppLogger +from ..core.settings import get_settings +from .orm import Base + +settings = get_settings() +logger = AppLogger.get_logger(__name__) +# SQLite 默认限制跨线程连接;FastAPI 请求线程会复用连接池,需要显式放开。 +connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {} +engine = create_engine(settings.database_url, connect_args=connect_args) +SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False) + +USER_PROFILE_COLUMNS = { + "avatar_url": { + "mysql": "VARCHAR(512) NOT NULL DEFAULT '' COMMENT '用户头像URL或静态资源路径'", + "sqlite": "VARCHAR(512) NOT NULL DEFAULT ''", + }, + "email": { + "mysql": "VARCHAR(128) NOT NULL DEFAULT '' COMMENT '用户邮箱'", + "sqlite": "VARCHAR(128) NOT NULL DEFAULT ''", + }, + "phone": { + "mysql": "VARCHAR(32) NOT NULL DEFAULT '' COMMENT '用户手机号'", + "sqlite": "VARCHAR(32) NOT NULL DEFAULT ''", + }, + "department": { + "mysql": "VARCHAR(128) NOT NULL DEFAULT '' COMMENT '用户所属部门'", + "sqlite": "VARCHAR(128) NOT NULL DEFAULT ''", + }, + "position_title": { + "mysql": "VARCHAR(128) NOT NULL DEFAULT '' COMMENT '用户职位或岗位名称'", + "sqlite": "VARCHAR(128) NOT NULL DEFAULT ''", + }, +} + +PAYROLL_RESULT_COLUMNS = { + "salary_month": { + "mysql": "VARCHAR(7) NOT NULL DEFAULT '' COMMENT '工资月份,格式YYYY-MM'", + "sqlite": "VARCHAR(7) NOT NULL DEFAULT ''", + }, + "salary_mode": { + "mysql": "VARCHAR(32) NOT NULL DEFAULT 'monthly' COMMENT '薪资模式快照'", + "sqlite": "VARCHAR(32) NOT NULL DEFAULT 'monthly'", + }, + "attendance_days": { + "mysql": "FLOAT NOT NULL DEFAULT 0 COMMENT '出勤天数'", + "sqlite": "FLOAT NOT NULL DEFAULT 0", + }, + "absence_days": { + "mysql": "FLOAT NOT NULL DEFAULT 0 COMMENT '缺勤天数'", + "sqlite": "FLOAT NOT NULL DEFAULT 0", + }, + "actual_work_hours": { + "mysql": "FLOAT NOT NULL DEFAULT 0 COMMENT '实际工作工时'", + "sqlite": "FLOAT NOT NULL DEFAULT 0", + }, + "workday_overtime_hours": { + "mysql": "FLOAT NOT NULL DEFAULT 0 COMMENT '工作日剩余可计费加班工时'", + "sqlite": "FLOAT NOT NULL DEFAULT 0", + }, + "weekend_overtime_hours": { + "mysql": "FLOAT NOT NULL DEFAULT 0 COMMENT '周末剩余可计费加班工时'", + "sqlite": "FLOAT NOT NULL DEFAULT 0", + }, + "holiday_overtime_hours": { + "mysql": "FLOAT NOT NULL DEFAULT 0 COMMENT '法定节假日剩余可计费加班工时'", + "sqlite": "FLOAT NOT NULL DEFAULT 0", + }, + "commission_amount": { + "mysql": "FLOAT NOT NULL DEFAULT 0 COMMENT '手工维护的提成费用'", + "sqlite": "FLOAT NOT NULL DEFAULT 0", + }, + "base_pay": { + "mysql": "FLOAT NULL COMMENT '按薪资模式计算后的基础工资'", + "sqlite": "FLOAT NULL", + }, + "weekend_overtime_rate": { + "mysql": "FLOAT NOT NULL DEFAULT 0 COMMENT '周末加班小时单价'", + "sqlite": "FLOAT NOT NULL DEFAULT 0", + }, + "holiday_overtime_rate": { + "mysql": "FLOAT NOT NULL DEFAULT 0 COMMENT '法定节假日加班小时单价'", + "sqlite": "FLOAT NOT NULL DEFAULT 0", + }, +} + +SALARY_PROFILE_COLUMNS = { + "salary_mode": { + "mysql": "VARCHAR(32) NOT NULL DEFAULT 'monthly' COMMENT '薪资模式:monthly包月、hourly计时、piecework计件、probation试用期'", + "sqlite": "VARCHAR(32) NOT NULL DEFAULT 'monthly'", + }, + "hourly_rate": { + "mysql": "FLOAT NOT NULL DEFAULT 0 COMMENT '计时工资小时单价'", + "sqlite": "FLOAT NOT NULL DEFAULT 0", + }, + "piece_quantity": { + "mysql": "FLOAT NOT NULL DEFAULT 0 COMMENT '计件数量'", + "sqlite": "FLOAT NOT NULL DEFAULT 0", + }, + "piece_unit_price": { + "mysql": "FLOAT NOT NULL DEFAULT 0 COMMENT '计件单价'", + "sqlite": "FLOAT NOT NULL DEFAULT 0", + }, + "probation_type": { + "mysql": "VARCHAR(32) NOT NULL DEFAULT 'ratio' COMMENT '试用期计算方式:ratio固定比例、fixed固定金额'", + "sqlite": "VARCHAR(32) NOT NULL DEFAULT 'ratio'", + }, + "probation_ratio": { + "mysql": "FLOAT NOT NULL DEFAULT 1 COMMENT '试用期工资比例'", + "sqlite": "FLOAT NOT NULL DEFAULT 1", + }, + "probation_salary": { + "mysql": "FLOAT NOT NULL DEFAULT 0 COMMENT '试用期固定工资'", + "sqlite": "FLOAT NOT NULL DEFAULT 0", + }, + "weekend_overtime_rate": { + "mysql": "FLOAT NOT NULL DEFAULT 0 COMMENT '周末加班费小时单价'", + "sqlite": "FLOAT NOT NULL DEFAULT 0", + }, + "holiday_overtime_rate": { + "mysql": "FLOAT NOT NULL DEFAULT 0 COMMENT '法定节假日加班费小时单价'", + "sqlite": "FLOAT NOT NULL DEFAULT 0", + }, +} + + +def init_database() -> None: + """创建 MySQL 库和缺失表;当前项目暂不引入迁移工具。""" + logger.info("数据库初始化开始 database_url=%s", _safe_database_url(settings.database_url)) + try: + _ensure_database_exists() + Base.metadata.create_all(bind=engine) + _ensure_user_profile_columns() + _ensure_salary_profile_columns() + _ensure_payroll_result_columns() + except Exception: + logger.exception("数据库初始化失败") + raise + logger.info("数据库初始化完成") + + +def _ensure_user_profile_columns() -> None: + """为已有 users 表补齐个人资料字段;create_all 不会修改旧表结构。""" + existing_columns = {column["name"] for column in inspect(engine).get_columns("users")} + missing_columns = [ + (column_name, definitions) + for column_name, definitions in USER_PROFILE_COLUMNS.items() + if column_name not in existing_columns + ] + if not missing_columns: + return + + dialect = "mysql" if settings.database_url.startswith("mysql") else "sqlite" + with engine.begin() as connection: + for column_name, definitions in missing_columns: + column_sql = definitions[dialect] + connection.execute(text(f"ALTER TABLE users ADD COLUMN `{column_name}` {column_sql}")) + logger.info("users 表字段已补齐 column=%s", column_name) + + +def _ensure_payroll_result_columns() -> None: + """为已有 payroll_results 表补齐工资结果新增字段。""" + if not inspect(engine).has_table("payroll_results"): + return + + existing_columns = {column["name"] for column in inspect(engine).get_columns("payroll_results")} + missing_columns = [ + (column_name, definitions) + for column_name, definitions in PAYROLL_RESULT_COLUMNS.items() + if column_name not in existing_columns + ] + if not missing_columns: + return + + dialect = "mysql" if settings.database_url.startswith("mysql") else "sqlite" + with engine.begin() as connection: + for column_name, definitions in missing_columns: + column_sql = definitions[dialect] + connection.execute(text(f"ALTER TABLE payroll_results ADD COLUMN `{column_name}` {column_sql}")) + logger.info("payroll_results 表字段已补齐 column=%s", column_name) + + +def _ensure_salary_profile_columns() -> None: + """为已有 salary_profiles 表补齐薪资模式和加班费单价字段。""" + if not inspect(engine).has_table("salary_profiles"): + return + + existing_columns = {column["name"] for column in inspect(engine).get_columns("salary_profiles")} + missing_columns = [ + (column_name, definitions) + for column_name, definitions in SALARY_PROFILE_COLUMNS.items() + if column_name not in existing_columns + ] + if not missing_columns: + return + + dialect = "mysql" if settings.database_url.startswith("mysql") else "sqlite" + with engine.begin() as connection: + for column_name, definitions in missing_columns: + column_sql = definitions[dialect] + connection.execute(text(f"ALTER TABLE salary_profiles ADD COLUMN `{column_name}` {column_sql}")) + logger.info("salary_profiles 表字段已补齐 column=%s", column_name) + + +def _ensure_database_exists() -> None: + if not settings.database_url.startswith("mysql"): + return + + url = make_url(settings.database_url) + if not url.database: + return + + database_name = url.database + admin_url = url.set(database=None) + admin_engine = create_engine(admin_url) + safe_database_name = database_name.replace("`", "``") + try: + with admin_engine.begin() as connection: + connection.execute( + text( + f"CREATE DATABASE IF NOT EXISTS `{safe_database_name}` " + "CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ) + ) + logger.info("MySQL 数据库检查完成 database=%s", database_name) + finally: + admin_engine.dispose() + + +def _safe_database_url(database_url: str) -> str: + try: + return str(make_url(database_url).render_as_string(hide_password=True)) + except Exception: + return "***invalid-database-url***" diff --git a/financial_system/database/sql/init_mysql.sql b/financial_system/database/sql/init_mysql.sql new file mode 100644 index 0000000..71f507e --- /dev/null +++ b/financial_system/database/sql/init_mysql.sql @@ -0,0 +1,613 @@ +-- Financial System full MySQL initialization script. +-- Execute with: +-- mysql -h localhost -P 3306 -u root -p12345678 < financial_system/database/sql/init_mysql.sql + +CREATE DATABASE IF NOT EXISTS `financial_system` + DEFAULT CHARACTER SET utf8mb4 + DEFAULT COLLATE utf8mb4_unicode_ci; + +USE `financial_system`; + +CREATE TABLE IF NOT EXISTS `users` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '用户主键ID', + `username` VARCHAR(64) NOT NULL COMMENT '登录用户名,全局唯一', + `display_name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '用户显示名称', + `avatar_url` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '用户头像URL或静态资源路径', + `email` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '用户邮箱', + `phone` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '用户手机号', + `department` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '用户所属部门', + `position_title` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '用户职位或岗位名称', + `password_hash` VARCHAR(256) NOT NULL COMMENT '密码哈希值,不保存明文密码', + `role` VARCHAR(32) NOT NULL COMMENT '用户角色:superuser、manager、viewer', + `is_active` BOOL NOT NULL DEFAULT TRUE COMMENT '是否启用账号', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `ix_users_username` (`username`), + KEY `ix_users_role` (`role`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='系统用户表,保存登录账号、角色和启用状态'; + +SET @has_avatar_url := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'avatar_url' +); +SET @ddl := IF(@has_avatar_url = 0, + 'ALTER TABLE `users` ADD COLUMN `avatar_url` VARCHAR(512) NOT NULL DEFAULT '''' COMMENT ''用户头像URL或静态资源路径''', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @has_email := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'email' +); +SET @ddl := IF(@has_email = 0, + 'ALTER TABLE `users` ADD COLUMN `email` VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''用户邮箱''', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @has_phone := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'phone' +); +SET @ddl := IF(@has_phone = 0, + 'ALTER TABLE `users` ADD COLUMN `phone` VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''用户手机号''', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @has_department := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'department' +); +SET @ddl := IF(@has_department = 0, + 'ALTER TABLE `users` ADD COLUMN `department` VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''用户所属部门''', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @has_position_title := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'position_title' +); +SET @ddl := IF(@has_position_title = 0, + 'ALTER TABLE `users` ADD COLUMN `position_title` VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''用户职位或岗位名称''', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +CREATE TABLE IF NOT EXISTS `departments` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '部门主键ID', + `parent_id` INT NULL COMMENT '父级部门ID,顶级部门为空', + `department_code` VARCHAR(64) NOT NULL COMMENT '部门编码,全局唯一', + `name` VARCHAR(128) NOT NULL COMMENT '部门名称', + `sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序号,越小越靠前', + `is_active` BOOL NOT NULL DEFAULT TRUE COMMENT '是否启用', + `remark` TEXT NOT NULL COMMENT '部门备注', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `ix_departments_department_code` (`department_code`), + UNIQUE KEY `ix_departments_name` (`name`), + KEY `ix_departments_parent_id` (`parent_id`), + KEY `ix_departments_is_active` (`is_active`), + CONSTRAINT `fk_departments_parent_id` + FOREIGN KEY (`parent_id`) REFERENCES `departments` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='部门表,维护组织架构中的部门及上下级关系'; + +CREATE TABLE IF NOT EXISTS `positions` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '岗位主键ID', + `department_id` INT NOT NULL COMMENT '关联部门ID', + `position_code` VARCHAR(64) NOT NULL COMMENT '岗位编码,全局唯一', + `name` VARCHAR(128) NOT NULL COMMENT '岗位名称', + `sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序号,越小越靠前', + `is_active` BOOL NOT NULL DEFAULT TRUE COMMENT '是否启用', + `remark` TEXT NOT NULL COMMENT '岗位备注', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `ix_positions_position_code` (`position_code`), + UNIQUE KEY `uq_positions_department_name` (`department_id`, `name`), + KEY `ix_positions_department_id` (`department_id`), + KEY `ix_positions_name` (`name`), + KEY `ix_positions_is_active` (`is_active`), + CONSTRAINT `fk_positions_department_id` + FOREIGN KEY (`department_id`) REFERENCES `departments` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='岗位表,维护部门下的职位/岗位'; + +SET @has_position_department_name_index := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'positions' + AND INDEX_NAME = 'uq_positions_department_name' +); +SET @ddl := IF(@has_position_department_name_index = 0, + 'ALTER TABLE `positions` ADD UNIQUE KEY `uq_positions_department_name` (`department_id`, `name`)', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +DROP TEMPORARY TABLE IF EXISTS `tmp_organization_departments`; +CREATE TEMPORARY TABLE `tmp_organization_departments` ( + `department_code` VARCHAR(64) NOT NULL, + `name` VARCHAR(128) NOT NULL, + `parent_name` VARCHAR(128) NULL, + `sort_order` INT NOT NULL, + `remark` VARCHAR(255) NOT NULL +) ENGINE=MEMORY; + +INSERT INTO `tmp_organization_departments` +(`department_code`, `name`, `parent_name`, `sort_order`, `remark`) VALUES +('ORG001', '总经办', NULL, 1, '系统初始化部门'), +('ORG002', '行政人事部', NULL, 2, '系统初始化部门'), +('ORG003', '财务部', NULL, 3, '系统初始化部门'), +('ORG004', '订单部', NULL, 4, '系统初始化部门'), +('ORG005', '工程部', NULL, 5, '系统根据工程部子部门自动补齐的父级部门'), +('ORG006', '工程部-安装队', '工程部', 6, '系统初始化部门'), +('ORG007', '工程部-项目预算', '工程部', 7, '系统初始化部门'), +('ORG008', '杭州运营中心', NULL, 8, '系统根据杭州运营中心子部门自动补齐的父级部门'), +('ORG009', '杭州运营中心-销售部', '杭州运营中心', 9, '系统初始化部门'), +('ORG010', '华南运营中心', NULL, 10, '系统初始化部门'), +('ORG011', '华中运营中心', NULL, 11, '系统初始化部门'), +('ORG012', '生产中心', NULL, 12, '系统初始化部门'), +('ORG013', '生产中心-采购部', '生产中心', 13, '系统初始化部门'), +('ORG014', '生产中心-仓库', '生产中心', 14, '系统初始化部门'), +('ORG015', '生产中心-品管部', '生产中心', 15, '系统初始化部门'), +('ORG016', '生产中心-生产部', '生产中心', 16, '系统初始化部门'), +('ORG017', '西南运营中心', NULL, 17, '系统初始化部门'), +('ORG018', '西南运营中心-办事处', '西南运营中心', 18, '系统初始化部门'), +('ORG019', '西南运营中心-销售部', '西南运营中心', 19, '系统初始化部门'); + +INSERT INTO `departments` (`department_code`, `name`, `sort_order`, `is_active`, `remark`) +SELECT `department_code`, `name`, `sort_order`, TRUE, `remark` +FROM `tmp_organization_departments` +ON DUPLICATE KEY UPDATE + `department_code` = VALUES(`department_code`), + `sort_order` = VALUES(`sort_order`), + `is_active` = VALUES(`is_active`), + `remark` = VALUES(`remark`); + +UPDATE `departments` child +JOIN `tmp_organization_departments` seed ON seed.name = child.name +LEFT JOIN `departments` parent ON parent.name = seed.parent_name +SET child.parent_id = parent.id; + +DROP TEMPORARY TABLE IF EXISTS `tmp_organization_positions`; +CREATE TEMPORARY TABLE `tmp_organization_positions` ( + `department_name` VARCHAR(128) NOT NULL, + `position_code` VARCHAR(64) NOT NULL, + `name` VARCHAR(128) NOT NULL, + `sort_order` INT NOT NULL +) ENGINE=MEMORY; + +INSERT INTO `tmp_organization_positions` +(`department_name`, `position_code`, `name`, `sort_order`) VALUES +('总经办', 'POS001', '总经理', 1), +('总经办', 'POS002', '副总经理', 2), +('总经办', 'POS003', '总经理助理', 3), +('行政人事部', 'POS004', '人事经理', 1), +('行政人事部', 'POS005', '人事专员', 2), +('财务部', 'POS006', '财务', 1), +('财务部', 'POS007', '出纳专员', 2), +('订单部', 'POS008', '销售助理', 1), +('订单部', 'POS009', '订单助理', 2), +('工程部-安装队', 'POS010', '经理助理', 1), +('工程部-安装队', 'POS011', '安装师傅', 2), +('工程部-安装队', 'POS012', '项目经理', 3), +('工程部-项目预算', 'POS013', '项目预算', 1), +('杭州运营中心-销售部', 'POS014', '销售总监', 1), +('杭州运营中心-销售部', 'POS015', '销售', 2), +('华南运营中心', 'POS016', '销售总监', 1), +('华中运营中心', 'POS017', '销售总监', 1), +('生产中心', 'POS018', '机械工程师', 1), +('生产中心-采购部', 'POS019', '采购', 1), +('生产中心-仓库', 'POS020', '仓库', 1), +('生产中心-仓库', 'POS021', '仓库物料员', 2), +('生产中心-品管部', 'POS022', '品管', 1), +('生产中心-生产部', 'POS023', '焊接师傅', 1), +('生产中心-生产部', 'POS024', '普工', 2), +('生产中心-生产部', 'POS025', '技工', 3), +('西南运营中心', 'POS026', '销售总监', 1), +('西南运营中心-办事处', 'POS027', '经理助理', 1), +('西南运营中心-销售部', 'POS028', '销售工程师', 1); + +INSERT INTO `positions` (`department_id`, `position_code`, `name`, `sort_order`, `is_active`, `remark`) +SELECT d.id, p.position_code, p.name, p.sort_order, TRUE, '系统初始化岗位' +FROM `tmp_organization_positions` p +JOIN `departments` d ON d.name = p.department_name +ON DUPLICATE KEY UPDATE + `department_id` = VALUES(`department_id`), + `position_code` = VALUES(`position_code`), + `name` = VALUES(`name`), + `sort_order` = VALUES(`sort_order`), + `is_active` = VALUES(`is_active`), + `remark` = VALUES(`remark`); + +DROP TEMPORARY TABLE IF EXISTS `tmp_organization_positions`; +DROP TEMPORARY TABLE IF EXISTS `tmp_organization_departments`; + +CREATE TABLE IF NOT EXISTS `employees` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '员工主键ID', + `employee_no` VARCHAR(64) NOT NULL COMMENT '员工编号,全局唯一,系统自动生成,默认ZA前缀', + `name` VARCHAR(128) NOT NULL COMMENT '员工姓名', + `dingtalk_user_id` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '钉钉用户ID,用于对接钉钉考勤', + `department` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '部门名称', + `position` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '岗位名称', + `phone` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '手机号', + `employment_status` VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '员工状态:active在职、inactive离职', + `remark` TEXT NOT NULL COMMENT '员工备注', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `ix_employees_employee_no` (`employee_no`), + KEY `ix_employees_name` (`name`), + KEY `ix_employees_dingtalk_user_id` (`dingtalk_user_id`), + KEY `ix_employees_employment_status` (`employment_status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='员工基础档案表,维护员工编号、姓名、部门、岗位和钉钉用户ID'; + +CREATE TABLE IF NOT EXISTS `salary_profiles` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '薪资档案主键ID', + `employee_id` INT NOT NULL COMMENT '关联员工ID,一名员工一条当前薪资档案', + `salary_mode` VARCHAR(32) NOT NULL DEFAULT 'monthly' COMMENT '薪资模式:monthly包月、hourly计时、piecework计件、probation试用期', + `base_salary` FLOAT NOT NULL DEFAULT 0 COMMENT '基础工资或底薪', + `hourly_rate` FLOAT NOT NULL DEFAULT 0 COMMENT '计时工资小时单价', + `piece_quantity` FLOAT NOT NULL DEFAULT 0 COMMENT '计件数量', + `piece_unit_price` FLOAT NOT NULL DEFAULT 0 COMMENT '计件单价', + `probation_type` VARCHAR(32) NOT NULL DEFAULT 'ratio' COMMENT '试用期计算方式:ratio固定比例、fixed固定金额', + `probation_ratio` FLOAT NOT NULL DEFAULT 1 COMMENT '试用期工资比例', + `probation_salary` FLOAT NOT NULL DEFAULT 0 COMMENT '试用期固定工资', + `commission_amount` FLOAT NOT NULL DEFAULT 0 COMMENT '默认提成金额,月度提成优先维护到commission_record', + `overtime_rate` FLOAT NOT NULL DEFAULT 0 COMMENT '工作日加班费小时单价,独立于基础工资维护', + `weekend_overtime_rate` FLOAT NOT NULL DEFAULT 0 COMMENT '周末加班费小时单价', + `holiday_overtime_rate` FLOAT NOT NULL DEFAULT 0 COMMENT '法定节假日加班费小时单价', + `effective_month` VARCHAR(7) NOT NULL DEFAULT '' COMMENT '生效月份,格式YYYY-MM;为空表示长期当前档案', + `remark` TEXT NOT NULL COMMENT '薪资备注', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `ix_salary_profiles_employee_id` (`employee_id`), + KEY `ix_salary_profiles_salary_mode` (`salary_mode`), + CONSTRAINT `fk_salary_profiles_employee_id` + FOREIGN KEY (`employee_id`) REFERENCES `employees` (`id`) + ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='员工薪资档案表,维护薪资模式、底薪、计时计件规则、默认提成和加班费单价'; + +CREATE TABLE IF NOT EXISTS `operation_logs` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '日志主键ID', + `user_id` INT NULL COMMENT '操作用户ID,匿名或用户删除后为空', + `username` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '操作用户名快照', + `role` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '操作用户角色快照', + `module` VARCHAR(64) NOT NULL COMMENT '业务模块,例如auth、payroll、system', + `action` VARCHAR(128) NOT NULL COMMENT '操作动作编码,例如login、payroll.excel.calculate', + `target_type` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '操作对象类型,例如user、payroll_job、file', + `target_id` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '操作对象ID或业务标识', + `status` VARCHAR(32) NOT NULL COMMENT '操作状态:success或failed', + `detail` TEXT NOT NULL COMMENT '操作详情或失败原因', + `ip_address` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '客户端IP地址', + `user_agent` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '客户端浏览器User-Agent', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间', + PRIMARY KEY (`id`), + KEY `ix_operation_logs_user_id` (`user_id`), + KEY `ix_operation_logs_username` (`username`), + KEY `ix_operation_logs_module` (`module`), + KEY `ix_operation_logs_action` (`action`), + KEY `ix_operation_logs_status` (`status`), + KEY `ix_operation_logs_created_at` (`created_at`), + CONSTRAINT `fk_operation_logs_user_id` + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) + ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='系统操作日志表,记录用户登录、资料维护、工资计算、下载和系统管理动作'; + +CREATE TABLE IF NOT EXISTS `payroll_jobs` ( + `id` VARCHAR(32) NOT NULL COMMENT '任务ID,UUID十六进制字符串', + `source_type` VARCHAR(32) NOT NULL COMMENT '任务来源:excel或dingtalk', + `status` VARCHAR(32) NOT NULL DEFAULT 'created' COMMENT '任务状态:running、completed、failed', + `input_file` TEXT NULL COMMENT '上传的原始Excel文件路径', + `output_file` TEXT NULL COMMENT '导出的工资结果Excel文件路径', + `employee_count` INT NOT NULL DEFAULT 0 COMMENT '本次任务计算的员工数量', + `error_message` TEXT NULL COMMENT '任务失败时的错误信息', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='工资计算任务表,记录每次Excel或钉钉实时计算任务'; + +CREATE TABLE IF NOT EXISTS `payroll_results` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '结果主键ID', + `job_id` VARCHAR(32) NOT NULL COMMENT '所属工资计算任务ID', + `name` VARCHAR(128) NOT NULL COMMENT '员工姓名', + `department` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '部门名称', + `position` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '岗位名称', + `attendance_group` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '考勤组名称', + `salary_month` VARCHAR(7) NOT NULL DEFAULT '' COMMENT '工资月份,格式YYYY-MM', + `salary_mode` VARCHAR(32) NOT NULL DEFAULT 'monthly' COMMENT '薪资模式快照', + `attendance_days` FLOAT NOT NULL DEFAULT 0 COMMENT '出勤天数', + `absence_days` FLOAT NOT NULL DEFAULT 0 COMMENT '缺勤天数', + `actual_work_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '实际工作工时', + `punch_overtime_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '根据下班打卡时间推算的加班工时', + `leave_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '请假或调休工时', + `remaining_overtime_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '剩余加班工时:打卡推算加班工时减请假调休工时', + `workday_overtime_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '工作日剩余可计费加班工时', + `weekend_overtime_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '周末剩余可计费加班工时', + `holiday_overtime_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '法定节假日剩余可计费加班工时', + `minor_late_count` INT NOT NULL DEFAULT 0 COMMENT '5分钟内迟到次数', + `major_late_count` INT NOT NULL DEFAULT 0 COMMENT '超过5分钟迟到次数', + `penalized_late_count` INT NOT NULL DEFAULT 0 COMMENT '实际计扣迟到次数', + `late_deduction` FLOAT NOT NULL DEFAULT 0 COMMENT '迟到扣款金额', + `missing_card_count` INT NOT NULL DEFAULT 0 COMMENT '缺卡次数', + `missing_card_deduction` FLOAT NOT NULL DEFAULT 0 COMMENT '缺卡扣款金额', + `total_deduction` FLOAT NOT NULL DEFAULT 0 COMMENT '扣款合计金额', + `base_salary` FLOAT NULL COMMENT '员工底薪', + `base_pay` FLOAT NULL COMMENT '按薪资模式计算后的基础工资', + `commission_amount` FLOAT NOT NULL DEFAULT 0 COMMENT '手工维护的提成费用', + `overtime_rate` FLOAT NOT NULL DEFAULT 0 COMMENT '加班小时单价', + `weekend_overtime_rate` FLOAT NOT NULL DEFAULT 0 COMMENT '周末加班小时单价', + `holiday_overtime_rate` FLOAT NOT NULL DEFAULT 0 COMMENT '法定节假日加班小时单价', + `overtime_pay` FLOAT NOT NULL DEFAULT 0 COMMENT '加班费金额', + `gross_salary` FLOAT NULL COMMENT '应发工资', + `net_salary` FLOAT NULL COMMENT '实发工资', + `note` TEXT NOT NULL COMMENT '备注', + PRIMARY KEY (`id`), + KEY `ix_payroll_results_job_id` (`job_id`), + KEY `ix_payroll_results_salary_month` (`salary_month`), + CONSTRAINT `fk_payroll_results_job_id` + FOREIGN KEY (`job_id`) REFERENCES `payroll_jobs` (`id`) + ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='员工工资计算结果表,保存任务下每个员工的加班、请假、扣款和工资结果'; + +SET @has_payroll_commission_amount := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'payroll_results' AND COLUMN_NAME = 'commission_amount' +); +SET @ddl := IF(@has_payroll_commission_amount = 0, + 'ALTER TABLE `payroll_results` ADD COLUMN `commission_amount` FLOAT NOT NULL DEFAULT 0 COMMENT ''手工维护的提成费用'' AFTER `base_salary`', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +CREATE TABLE IF NOT EXISTS `attendance_record` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '考勤记录主键ID', + `job_id` VARCHAR(32) NULL COMMENT '关联工资计算任务ID', + `employee_id` INT NULL COMMENT '关联员工ID,未匹配员工时为空', + `employee_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '员工编号快照', + `employee_name` VARCHAR(128) NOT NULL COMMENT '员工姓名快照', + `department` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '部门名称快照', + `work_date` DATE NOT NULL COMMENT '考勤日期', + `first_punch` VARCHAR(5) NOT NULL DEFAULT '' COMMENT '上班打卡时间HH:MM', + `last_punch` VARCHAR(5) NOT NULL DEFAULT '' COMMENT '下班打卡时间HH:MM', + `attendance_status` VARCHAR(32) NOT NULL DEFAULT 'present' COMMENT '考勤状态:present出勤、absent缺勤、leave请假、missing缺卡', + `late_minutes` INT NOT NULL DEFAULT 0 COMMENT '当日迟到分钟合计', + `missing_card_count` INT NOT NULL DEFAULT 0 COMMENT '当日缺卡次数', + `leave_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '当日请假或调休工时', + `overtime_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '当日打卡推算加班工时', + `raw_text` TEXT NOT NULL COMMENT '原始考勤单元格内容', + `source_type` VARCHAR(32) NOT NULL DEFAULT 'excel' COMMENT '来源:excel或dingtalk', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `ix_attendance_record_job_id` (`job_id`), + KEY `ix_attendance_record_employee_id` (`employee_id`), + KEY `ix_attendance_record_employee_no` (`employee_no`), + KEY `ix_attendance_record_employee_name` (`employee_name`), + KEY `ix_attendance_record_work_date` (`work_date`), + KEY `ix_attendance_record_attendance_status` (`attendance_status`), + CONSTRAINT `fk_attendance_record_job_id` + FOREIGN KEY (`job_id`) REFERENCES `payroll_jobs` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_attendance_record_employee_id` + FOREIGN KEY (`employee_id`) REFERENCES `employees` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='考勤记录表,保存标准化后的每日打卡、迟到、缺卡、请假和加班工时'; + +CREATE TABLE IF NOT EXISTS `leave_record` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '请假记录主键ID', + `job_id` VARCHAR(32) NULL COMMENT '关联工资计算任务ID', + `employee_id` INT NULL COMMENT '关联员工ID,未匹配员工时为空', + `employee_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '员工编号快照', + `employee_name` VARCHAR(128) NOT NULL COMMENT '员工姓名快照', + `leave_type` VARCHAR(32) NOT NULL COMMENT '请假类型', + `start_text` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '原始开始时间文本', + `end_text` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '原始结束时间文本', + `hours` FLOAT NOT NULL DEFAULT 0 COMMENT '请假工时', + `source_type` VARCHAR(32) NOT NULL DEFAULT 'excel' COMMENT '来源:excel或dingtalk', + `status` VARCHAR(32) NOT NULL DEFAULT 'approved' COMMENT '状态:approved已生效', + `remark` TEXT NOT NULL COMMENT '备注', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `ix_leave_record_job_id` (`job_id`), + KEY `ix_leave_record_employee_id` (`employee_id`), + KEY `ix_leave_record_employee_no` (`employee_no`), + KEY `ix_leave_record_employee_name` (`employee_name`), + KEY `ix_leave_record_leave_type` (`leave_type`), + CONSTRAINT `fk_leave_record_job_id` + FOREIGN KEY (`job_id`) REFERENCES `payroll_jobs` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_leave_record_employee_id` + FOREIGN KEY (`employee_id`) REFERENCES `employees` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='请假记录表,保存事假、病假、年假、调休等统一工时记录'; + +CREATE TABLE IF NOT EXISTS `overtime_record` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '加班记录主键ID', + `job_id` VARCHAR(32) NULL COMMENT '关联工资计算任务ID', + `employee_id` INT NULL COMMENT '关联员工ID,未匹配员工时为空', + `employee_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '员工编号快照', + `employee_name` VARCHAR(128) NOT NULL COMMENT '员工姓名快照', + `work_date` DATE NOT NULL COMMENT '加班日期', + `overtime_type` VARCHAR(32) NOT NULL DEFAULT 'workday' COMMENT '加班类型:workday工作日、weekend周末、holiday法定节假日', + `hours` FLOAT NOT NULL DEFAULT 0 COMMENT '加班工时', + `rate` FLOAT NOT NULL DEFAULT 0 COMMENT '加班小时单价', + `amount` FLOAT NOT NULL DEFAULT 0 COMMENT '加班费金额', + `source_type` VARCHAR(32) NOT NULL DEFAULT 'excel' COMMENT '来源:excel或dingtalk', + `status` VARCHAR(32) NOT NULL DEFAULT 'calculated' COMMENT '状态:calculated已计算', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `ix_overtime_record_job_id` (`job_id`), + KEY `ix_overtime_record_employee_id` (`employee_id`), + KEY `ix_overtime_record_employee_no` (`employee_no`), + KEY `ix_overtime_record_employee_name` (`employee_name`), + KEY `ix_overtime_record_work_date` (`work_date`), + KEY `ix_overtime_record_overtime_type` (`overtime_type`), + CONSTRAINT `fk_overtime_record_job_id` + FOREIGN KEY (`job_id`) REFERENCES `payroll_jobs` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_overtime_record_employee_id` + FOREIGN KEY (`employee_id`) REFERENCES `employees` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='加班记录表,保存每日加班工时、加班类型、单价和金额'; + +CREATE TABLE IF NOT EXISTS `commission_record` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '提成记录主键ID', + `employee_id` INT NOT NULL COMMENT '关联员工ID', + `commission_month` VARCHAR(7) NOT NULL COMMENT '提成月份,格式YYYY-MM', + `commission_type` VARCHAR(64) NOT NULL DEFAULT '销售提成' COMMENT '提成类型', + `amount` FLOAT NOT NULL DEFAULT 0 COMMENT '提成金额', + `source_type` VARCHAR(32) NOT NULL DEFAULT 'manual' COMMENT '来源:manual手工、excel导入', + `business_ref` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '业务单号或来源标识', + `remark` TEXT NOT NULL COMMENT '备注', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `ix_commission_record_employee_id` (`employee_id`), + KEY `ix_commission_record_commission_month` (`commission_month`), + CONSTRAINT `fk_commission_record_employee_id` + FOREIGN KEY (`employee_id`) REFERENCES `employees` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='提成记录表,保存销售提成、项目奖金、绩效奖励等月度金额'; + +CREATE TABLE IF NOT EXISTS `salary_record` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '工资记录主键ID', + `job_id` VARCHAR(32) NULL COMMENT '关联工资计算任务ID', + `employee_id` INT NULL COMMENT '关联员工ID,未匹配员工时为空', + `employee_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '员工编号快照', + `employee_name` VARCHAR(128) NOT NULL COMMENT '员工姓名快照', + `department` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '部门名称快照', + `salary_month` VARCHAR(7) NOT NULL COMMENT '工资月份,格式YYYY-MM', + `salary_mode` VARCHAR(32) NOT NULL DEFAULT 'monthly' COMMENT '薪资模式快照', + `base_pay` FLOAT NOT NULL DEFAULT 0 COMMENT '基础工资金额', + `commission_amount` FLOAT NOT NULL DEFAULT 0 COMMENT '提成金额', + `overtime_pay` FLOAT NOT NULL DEFAULT 0 COMMENT '加班费金额', + `deduction_amount` FLOAT NOT NULL DEFAULT 0 COMMENT '扣款合计金额', + `gross_salary` FLOAT NOT NULL DEFAULT 0 COMMENT '应发工资', + `net_salary` FLOAT NOT NULL DEFAULT 0 COMMENT '实发工资', + `status` VARCHAR(32) NOT NULL DEFAULT 'calculated' COMMENT '状态:calculated已计算', + `remark` TEXT NOT NULL COMMENT '备注', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `ix_salary_record_job_id` (`job_id`), + KEY `ix_salary_record_employee_id` (`employee_id`), + KEY `ix_salary_record_employee_no` (`employee_no`), + KEY `ix_salary_record_employee_name` (`employee_name`), + KEY `ix_salary_record_department` (`department`), + KEY `ix_salary_record_salary_month` (`salary_month`), + CONSTRAINT `fk_salary_record_job_id` + FOREIGN KEY (`job_id`) REFERENCES `payroll_jobs` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_salary_record_employee_id` + FOREIGN KEY (`employee_id`) REFERENCES `employees` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='工资记录表,保存员工月度工资汇总结果'; + +CREATE TABLE IF NOT EXISTS `salary_detail` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '工资明细主键ID', + `salary_record_id` INT NOT NULL COMMENT '关联工资记录ID', + `item_type` VARCHAR(32) NOT NULL COMMENT '工资项类型:earning收入、deduction扣款、stat统计', + `item_name` VARCHAR(64) NOT NULL COMMENT '工资项名称', + `amount` FLOAT NOT NULL DEFAULT 0 COMMENT '金额', + `quantity` FLOAT NOT NULL DEFAULT 0 COMMENT '数量或工时', + `unit_price` FLOAT NOT NULL DEFAULT 0 COMMENT '单价', + `remark` TEXT NOT NULL COMMENT '备注', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `ix_salary_detail_salary_record_id` (`salary_record_id`), + KEY `ix_salary_detail_item_type` (`item_type`), + CONSTRAINT `fk_salary_detail_salary_record_id` + FOREIGN KEY (`salary_record_id`) REFERENCES `salary_record` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='工资明细表,保存工资记录下的每个工资项'; + +CREATE TABLE IF NOT EXISTS `salary_config` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '配置主键ID', + `config_key` VARCHAR(128) NOT NULL COMMENT '配置键', + `config_name` VARCHAR(128) NOT NULL COMMENT '配置名称', + `config_group` VARCHAR(64) NOT NULL COMMENT '配置分组', + `config_value` TEXT NOT NULL COMMENT '配置值,按value_type解析', + `value_type` VARCHAR(32) NOT NULL DEFAULT 'string' COMMENT '值类型:string、number、integer、boolean、json', + `is_enabled` BOOL NOT NULL DEFAULT TRUE COMMENT '是否启用', + `remark` TEXT NOT NULL COMMENT '配置说明', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `ix_salary_config_config_key` (`config_key`), + KEY `ix_salary_config_config_group` (`config_group`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='系统配置表,保存可后台维护的考勤、薪资和集成规则'; + +INSERT INTO `salary_config` (`config_key`, `config_name`, `config_group`, `config_value`, `value_type`, `is_enabled`, `remark`) VALUES +('attendance.on_work_time', '上班时间', 'attendance', '08:30', 'string', TRUE, '用于迟到识别,格式HH:MM'), +('attendance.off_work_time', '下班时间', 'attendance', '17:00', 'string', TRUE, '加班按下班打卡超过该时间后向下取整'), +('attendance.overtime_round_minutes', '加班取整分钟', 'attendance', '60', 'integer', TRUE, '例如60表示20:30按3小时计算'), +('attendance.standard_daily_hours', '标准日工时', 'attendance', '8', 'number', TRUE, '计时工资按出勤天数折算工时'), +('attendance.minor_late_minutes', '轻微迟到分钟', 'attendance', '5', 'integer', TRUE, '小于等于该分钟数按轻微迟到统计'), +('attendance.minor_late_free_times', '轻微迟到免扣次数', 'attendance', '3', 'integer', TRUE, '5分钟内迟到3次内合格,超过后按次扣款'), +('attendance.late_penalty', '迟到扣款', 'attendance', '30', 'number', TRUE, '每次计扣迟到扣款金额'), +('attendance.missing_card_penalty', '缺卡扣款', 'attendance', '20', 'number', TRUE, '每次缺卡扣款金额'), +('attendance.weekend_days', '周末星期', 'attendance', '[5, 6]', 'json', TRUE, 'Python weekday:周一0,周六5,周日6'), +('attendance.legal_holidays', '法定节假日', 'attendance', '[]', 'json', TRUE, '日期数组,例如["2026-10-01"]'), +('attendance.leave_keywords', '请假关键字', 'attendance', '["调休", "请假", "事假", "病假", "年假", "婚假", "产假", "陪产假", "丧假"]', 'json', TRUE, '从钉钉单元格识别请假/调休类型'), +('payroll.default_overtime_rate', '工作日加班单价', 'payroll', '0', 'number', TRUE, '未维护员工单价时使用'), +('payroll.default_weekend_overtime_rate', '周末加班单价', 'payroll', '0', 'number', TRUE, '未维护员工周末单价时使用'), +('payroll.default_holiday_overtime_rate', '节假日加班单价', 'payroll', '0', 'number', TRUE, '未维护员工节假日单价时使用') +ON DUPLICATE KEY UPDATE `config_key` = VALUES(`config_key`); + +INSERT INTO `users` ( + `username`, + `display_name`, + `avatar_url`, + `email`, + `phone`, + `department`, + `position_title`, + `password_hash`, + `role`, + `is_active`, + `created_at`, + `updated_at` +) VALUES ( + 'admin', + '超级管理员', + '', + '', + '', + '系统管理', + '超级管理员', + 'pbkdf2_sha256$260000$b3a9c1TDdorYhXOj4usDsA$BPH38v3hjJ-vj8tGw-senDM5czyhRX-wr_zS8lV4bBo', + 'superuser', + TRUE, + NOW(), + NOW() +) ON DUPLICATE KEY UPDATE + `display_name` = VALUES(`display_name`), + `department` = VALUES(`department`), + `position_title` = VALUES(`position_title`), + `role` = VALUES(`role`), + `is_active` = VALUES(`is_active`), + `updated_at` = NOW(); diff --git a/financial_system/database/sql/schema.sql b/financial_system/database/sql/schema.sql new file mode 100644 index 0000000..a5b3540 --- /dev/null +++ b/financial_system/database/sql/schema.sql @@ -0,0 +1,489 @@ +-- Financial System MySQL schema. +-- 新库建表脚本:创建 financial_system 数据库、全部业务表和默认规则配置。 + +CREATE DATABASE IF NOT EXISTS `financial_system` + DEFAULT CHARACTER SET utf8mb4 + DEFAULT COLLATE utf8mb4_unicode_ci; + +USE `financial_system`; + +CREATE TABLE IF NOT EXISTS `users` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '用户主键ID', + `username` VARCHAR(64) NOT NULL COMMENT '登录用户名,全局唯一', + `display_name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '用户显示名称', + `avatar_url` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '用户头像URL或静态资源路径', + `email` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '用户邮箱', + `phone` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '用户手机号', + `department` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '用户所属部门', + `position_title` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '用户职位或岗位名称', + `password_hash` VARCHAR(256) NOT NULL COMMENT '密码哈希值,不保存明文密码', + `role` VARCHAR(32) NOT NULL COMMENT '用户角色:superuser、manager、viewer', + `is_active` BOOL NOT NULL DEFAULT TRUE COMMENT '是否启用账号', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `ix_users_username` (`username`), + KEY `ix_users_role` (`role`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='系统用户表,保存登录账号、角色和启用状态'; + +CREATE TABLE IF NOT EXISTS `departments` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '部门主键ID', + `parent_id` INT NULL COMMENT '父级部门ID,顶级部门为空', + `department_code` VARCHAR(64) NOT NULL COMMENT '部门编码,全局唯一', + `name` VARCHAR(128) NOT NULL COMMENT '部门名称', + `sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序号,越小越靠前', + `is_active` BOOL NOT NULL DEFAULT TRUE COMMENT '是否启用', + `remark` TEXT NOT NULL COMMENT '部门备注', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `ix_departments_department_code` (`department_code`), + UNIQUE KEY `ix_departments_name` (`name`), + KEY `ix_departments_parent_id` (`parent_id`), + KEY `ix_departments_is_active` (`is_active`), + CONSTRAINT `fk_departments_parent_id` + FOREIGN KEY (`parent_id`) REFERENCES `departments` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='部门表,维护组织架构中的部门及上下级关系'; + +CREATE TABLE IF NOT EXISTS `positions` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '岗位主键ID', + `department_id` INT NOT NULL COMMENT '关联部门ID', + `position_code` VARCHAR(64) NOT NULL COMMENT '岗位编码,全局唯一', + `name` VARCHAR(128) NOT NULL COMMENT '岗位名称', + `sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序号,越小越靠前', + `is_active` BOOL NOT NULL DEFAULT TRUE COMMENT '是否启用', + `remark` TEXT NOT NULL COMMENT '岗位备注', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `ix_positions_position_code` (`position_code`), + UNIQUE KEY `uq_positions_department_name` (`department_id`, `name`), + KEY `ix_positions_department_id` (`department_id`), + KEY `ix_positions_name` (`name`), + KEY `ix_positions_is_active` (`is_active`), + CONSTRAINT `fk_positions_department_id` + FOREIGN KEY (`department_id`) REFERENCES `departments` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='岗位表,维护部门下的职位/岗位'; + +DROP TEMPORARY TABLE IF EXISTS `tmp_organization_departments`; +CREATE TEMPORARY TABLE `tmp_organization_departments` ( + `department_code` VARCHAR(64) NOT NULL, + `name` VARCHAR(128) NOT NULL, + `parent_name` VARCHAR(128) NULL, + `sort_order` INT NOT NULL, + `remark` VARCHAR(255) NOT NULL +) ENGINE=MEMORY; + +INSERT INTO `tmp_organization_departments` +(`department_code`, `name`, `parent_name`, `sort_order`, `remark`) VALUES +('ORG001', '总经办', NULL, 1, '系统初始化部门'), +('ORG002', '行政人事部', NULL, 2, '系统初始化部门'), +('ORG003', '财务部', NULL, 3, '系统初始化部门'), +('ORG004', '订单部', NULL, 4, '系统初始化部门'), +('ORG005', '工程部', NULL, 5, '系统根据工程部子部门自动补齐的父级部门'), +('ORG006', '工程部-安装队', '工程部', 6, '系统初始化部门'), +('ORG007', '工程部-项目预算', '工程部', 7, '系统初始化部门'), +('ORG008', '杭州运营中心', NULL, 8, '系统根据杭州运营中心子部门自动补齐的父级部门'), +('ORG009', '杭州运营中心-销售部', '杭州运营中心', 9, '系统初始化部门'), +('ORG010', '华南运营中心', NULL, 10, '系统初始化部门'), +('ORG011', '华中运营中心', NULL, 11, '系统初始化部门'), +('ORG012', '生产中心', NULL, 12, '系统初始化部门'), +('ORG013', '生产中心-采购部', '生产中心', 13, '系统初始化部门'), +('ORG014', '生产中心-仓库', '生产中心', 14, '系统初始化部门'), +('ORG015', '生产中心-品管部', '生产中心', 15, '系统初始化部门'), +('ORG016', '生产中心-生产部', '生产中心', 16, '系统初始化部门'), +('ORG017', '西南运营中心', NULL, 17, '系统初始化部门'), +('ORG018', '西南运营中心-办事处', '西南运营中心', 18, '系统初始化部门'), +('ORG019', '西南运营中心-销售部', '西南运营中心', 19, '系统初始化部门'); + +INSERT INTO `departments` (`department_code`, `name`, `sort_order`, `is_active`, `remark`) +SELECT `department_code`, `name`, `sort_order`, TRUE, `remark` +FROM `tmp_organization_departments` +ON DUPLICATE KEY UPDATE + `department_code` = VALUES(`department_code`), + `sort_order` = VALUES(`sort_order`), + `is_active` = VALUES(`is_active`), + `remark` = VALUES(`remark`); + +UPDATE `departments` child +JOIN `tmp_organization_departments` seed ON seed.name = child.name +LEFT JOIN `departments` parent ON parent.name = seed.parent_name +SET child.parent_id = parent.id; + +DROP TEMPORARY TABLE IF EXISTS `tmp_organization_positions`; +CREATE TEMPORARY TABLE `tmp_organization_positions` ( + `department_name` VARCHAR(128) NOT NULL, + `position_code` VARCHAR(64) NOT NULL, + `name` VARCHAR(128) NOT NULL, + `sort_order` INT NOT NULL +) ENGINE=MEMORY; + +INSERT INTO `tmp_organization_positions` +(`department_name`, `position_code`, `name`, `sort_order`) VALUES +('总经办', 'POS001', '总经理', 1), +('总经办', 'POS002', '副总经理', 2), +('总经办', 'POS003', '总经理助理', 3), +('行政人事部', 'POS004', '人事经理', 1), +('行政人事部', 'POS005', '人事专员', 2), +('财务部', 'POS006', '财务', 1), +('财务部', 'POS007', '出纳专员', 2), +('订单部', 'POS008', '销售助理', 1), +('订单部', 'POS009', '订单助理', 2), +('工程部-安装队', 'POS010', '经理助理', 1), +('工程部-安装队', 'POS011', '安装师傅', 2), +('工程部-安装队', 'POS012', '项目经理', 3), +('工程部-项目预算', 'POS013', '项目预算', 1), +('杭州运营中心-销售部', 'POS014', '销售总监', 1), +('杭州运营中心-销售部', 'POS015', '销售', 2), +('华南运营中心', 'POS016', '销售总监', 1), +('华中运营中心', 'POS017', '销售总监', 1), +('生产中心', 'POS018', '机械工程师', 1), +('生产中心-采购部', 'POS019', '采购', 1), +('生产中心-仓库', 'POS020', '仓库', 1), +('生产中心-仓库', 'POS021', '仓库物料员', 2), +('生产中心-品管部', 'POS022', '品管', 1), +('生产中心-生产部', 'POS023', '焊接师傅', 1), +('生产中心-生产部', 'POS024', '普工', 2), +('生产中心-生产部', 'POS025', '技工', 3), +('西南运营中心', 'POS026', '销售总监', 1), +('西南运营中心-办事处', 'POS027', '经理助理', 1), +('西南运营中心-销售部', 'POS028', '销售工程师', 1); + +INSERT INTO `positions` (`department_id`, `position_code`, `name`, `sort_order`, `is_active`, `remark`) +SELECT d.id, p.position_code, p.name, p.sort_order, TRUE, '系统初始化岗位' +FROM `tmp_organization_positions` p +JOIN `departments` d ON d.name = p.department_name +ON DUPLICATE KEY UPDATE + `department_id` = VALUES(`department_id`), + `position_code` = VALUES(`position_code`), + `name` = VALUES(`name`), + `sort_order` = VALUES(`sort_order`), + `is_active` = VALUES(`is_active`), + `remark` = VALUES(`remark`); + +DROP TEMPORARY TABLE IF EXISTS `tmp_organization_positions`; +DROP TEMPORARY TABLE IF EXISTS `tmp_organization_departments`; + +CREATE TABLE IF NOT EXISTS `employees` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '员工主键ID', + `employee_no` VARCHAR(64) NOT NULL COMMENT '员工编号,全局唯一,系统自动生成,默认ZA前缀', + `name` VARCHAR(128) NOT NULL COMMENT '员工姓名', + `dingtalk_user_id` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '钉钉用户ID,用于对接钉钉考勤', + `department` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '部门名称', + `position` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '岗位名称', + `phone` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '手机号', + `employment_status` VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '员工状态:active在职、inactive离职', + `remark` TEXT NOT NULL COMMENT '员工备注', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `ix_employees_employee_no` (`employee_no`), + KEY `ix_employees_name` (`name`), + KEY `ix_employees_dingtalk_user_id` (`dingtalk_user_id`), + KEY `ix_employees_employment_status` (`employment_status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='员工基础档案表,维护员工编号、姓名、部门、岗位和钉钉用户ID'; + +CREATE TABLE IF NOT EXISTS `salary_profiles` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '薪资档案主键ID', + `employee_id` INT NOT NULL COMMENT '关联员工ID,一名员工一条当前薪资档案', + `salary_mode` VARCHAR(32) NOT NULL DEFAULT 'monthly' COMMENT '薪资模式:monthly包月、hourly计时、piecework计件、probation试用期', + `base_salary` FLOAT NOT NULL DEFAULT 0 COMMENT '基础工资或底薪', + `hourly_rate` FLOAT NOT NULL DEFAULT 0 COMMENT '计时工资小时单价', + `piece_quantity` FLOAT NOT NULL DEFAULT 0 COMMENT '计件数量', + `piece_unit_price` FLOAT NOT NULL DEFAULT 0 COMMENT '计件单价', + `probation_type` VARCHAR(32) NOT NULL DEFAULT 'ratio' COMMENT '试用期计算方式:ratio固定比例、fixed固定金额', + `probation_ratio` FLOAT NOT NULL DEFAULT 1 COMMENT '试用期工资比例', + `probation_salary` FLOAT NOT NULL DEFAULT 0 COMMENT '试用期固定工资', + `commission_amount` FLOAT NOT NULL DEFAULT 0 COMMENT '默认提成金额,月度提成优先维护到commission_record', + `overtime_rate` FLOAT NOT NULL DEFAULT 0 COMMENT '工作日加班费小时单价,独立于基础工资维护', + `weekend_overtime_rate` FLOAT NOT NULL DEFAULT 0 COMMENT '周末加班费小时单价', + `holiday_overtime_rate` FLOAT NOT NULL DEFAULT 0 COMMENT '法定节假日加班费小时单价', + `effective_month` VARCHAR(7) NOT NULL DEFAULT '' COMMENT '生效月份,格式YYYY-MM;为空表示长期当前档案', + `remark` TEXT NOT NULL COMMENT '薪资备注', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `ix_salary_profiles_employee_id` (`employee_id`), + KEY `ix_salary_profiles_salary_mode` (`salary_mode`), + CONSTRAINT `fk_salary_profiles_employee_id` + FOREIGN KEY (`employee_id`) REFERENCES `employees` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='员工薪资档案表,维护薪资模式、底薪、计时计件规则、默认提成和加班费单价'; + +CREATE TABLE IF NOT EXISTS `operation_logs` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '日志主键ID', + `user_id` INT NULL COMMENT '操作用户ID,匿名或用户删除后为空', + `username` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '操作用户名快照', + `role` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '操作用户角色快照', + `module` VARCHAR(64) NOT NULL COMMENT '业务模块,例如auth、payroll、system', + `action` VARCHAR(128) NOT NULL COMMENT '操作动作编码,例如login、payroll.excel.calculate', + `target_type` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '操作对象类型,例如user、payroll_job、file', + `target_id` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '操作对象ID或业务标识', + `status` VARCHAR(32) NOT NULL COMMENT '操作状态:success或failed', + `detail` TEXT NOT NULL COMMENT '操作详情或失败原因', + `ip_address` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '客户端IP地址', + `user_agent` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '客户端浏览器User-Agent', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间', + PRIMARY KEY (`id`), + KEY `ix_operation_logs_user_id` (`user_id`), + KEY `ix_operation_logs_username` (`username`), + KEY `ix_operation_logs_module` (`module`), + KEY `ix_operation_logs_action` (`action`), + KEY `ix_operation_logs_status` (`status`), + KEY `ix_operation_logs_created_at` (`created_at`), + CONSTRAINT `fk_operation_logs_user_id` + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='系统操作日志表,记录用户登录、资料维护、工资计算、下载和系统管理动作'; + +CREATE TABLE IF NOT EXISTS `payroll_jobs` ( + `id` VARCHAR(32) NOT NULL COMMENT '任务ID,UUID十六进制字符串', + `source_type` VARCHAR(32) NOT NULL COMMENT '任务来源:excel或dingtalk', + `status` VARCHAR(32) NOT NULL DEFAULT 'created' COMMENT '任务状态:running、completed、failed', + `input_file` TEXT NULL COMMENT '上传的原始Excel文件路径', + `output_file` TEXT NULL COMMENT '导出的工资结果Excel文件路径', + `employee_count` INT NOT NULL DEFAULT 0 COMMENT '本次任务计算的员工数量', + `error_message` TEXT NULL COMMENT '任务失败时的错误信息', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='工资计算任务表,记录每次Excel或钉钉实时计算任务'; + +CREATE TABLE IF NOT EXISTS `payroll_results` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '结果主键ID', + `job_id` VARCHAR(32) NOT NULL COMMENT '所属工资计算任务ID', + `name` VARCHAR(128) NOT NULL COMMENT '员工姓名', + `department` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '部门名称', + `position` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '岗位名称', + `attendance_group` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '考勤组名称', + `salary_month` VARCHAR(7) NOT NULL DEFAULT '' COMMENT '工资月份,格式YYYY-MM', + `salary_mode` VARCHAR(32) NOT NULL DEFAULT 'monthly' COMMENT '薪资模式快照', + `attendance_days` FLOAT NOT NULL DEFAULT 0 COMMENT '出勤天数', + `absence_days` FLOAT NOT NULL DEFAULT 0 COMMENT '缺勤天数', + `actual_work_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '实际工作工时', + `punch_overtime_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '根据下班打卡时间推算的加班工时', + `leave_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '请假或调休工时', + `remaining_overtime_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '剩余加班工时:打卡推算加班工时减请假调休工时', + `workday_overtime_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '工作日剩余可计费加班工时', + `weekend_overtime_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '周末剩余可计费加班工时', + `holiday_overtime_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '法定节假日剩余可计费加班工时', + `minor_late_count` INT NOT NULL DEFAULT 0 COMMENT '5分钟内迟到次数', + `major_late_count` INT NOT NULL DEFAULT 0 COMMENT '超过5分钟迟到次数', + `penalized_late_count` INT NOT NULL DEFAULT 0 COMMENT '实际计扣迟到次数', + `late_deduction` FLOAT NOT NULL DEFAULT 0 COMMENT '迟到扣款金额', + `missing_card_count` INT NOT NULL DEFAULT 0 COMMENT '缺卡次数', + `missing_card_deduction` FLOAT NOT NULL DEFAULT 0 COMMENT '缺卡扣款金额', + `total_deduction` FLOAT NOT NULL DEFAULT 0 COMMENT '扣款合计金额', + `base_salary` FLOAT NULL COMMENT '员工底薪', + `base_pay` FLOAT NULL COMMENT '按薪资模式计算后的基础工资', + `commission_amount` FLOAT NOT NULL DEFAULT 0 COMMENT '手工维护的提成费用', + `overtime_rate` FLOAT NOT NULL DEFAULT 0 COMMENT '加班小时单价', + `weekend_overtime_rate` FLOAT NOT NULL DEFAULT 0 COMMENT '周末加班小时单价', + `holiday_overtime_rate` FLOAT NOT NULL DEFAULT 0 COMMENT '法定节假日加班小时单价', + `overtime_pay` FLOAT NOT NULL DEFAULT 0 COMMENT '加班费金额', + `gross_salary` FLOAT NULL COMMENT '应发工资', + `net_salary` FLOAT NULL COMMENT '实发工资', + `note` TEXT NOT NULL COMMENT '备注', + PRIMARY KEY (`id`), + KEY `ix_payroll_results_job_id` (`job_id`), + KEY `ix_payroll_results_salary_month` (`salary_month`), + CONSTRAINT `fk_payroll_results_job_id` + FOREIGN KEY (`job_id`) REFERENCES `payroll_jobs` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='员工工资计算结果表,保存任务下每个员工的加班、请假、扣款和工资结果'; + +CREATE TABLE IF NOT EXISTS `attendance_record` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '考勤记录主键ID', + `job_id` VARCHAR(32) NULL COMMENT '关联工资计算任务ID', + `employee_id` INT NULL COMMENT '关联员工ID,未匹配员工时为空', + `employee_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '员工编号快照', + `employee_name` VARCHAR(128) NOT NULL COMMENT '员工姓名快照', + `department` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '部门名称快照', + `work_date` DATE NOT NULL COMMENT '考勤日期', + `first_punch` VARCHAR(5) NOT NULL DEFAULT '' COMMENT '上班打卡时间HH:MM', + `last_punch` VARCHAR(5) NOT NULL DEFAULT '' COMMENT '下班打卡时间HH:MM', + `attendance_status` VARCHAR(32) NOT NULL DEFAULT 'present' COMMENT '考勤状态:present出勤、absent缺勤、leave请假、missing缺卡', + `late_minutes` INT NOT NULL DEFAULT 0 COMMENT '当日迟到分钟合计', + `missing_card_count` INT NOT NULL DEFAULT 0 COMMENT '当日缺卡次数', + `leave_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '当日请假或调休工时', + `overtime_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '当日打卡推算加班工时', + `raw_text` TEXT NOT NULL COMMENT '原始考勤单元格内容', + `source_type` VARCHAR(32) NOT NULL DEFAULT 'excel' COMMENT '来源:excel或dingtalk', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `ix_attendance_record_job_id` (`job_id`), + KEY `ix_attendance_record_employee_id` (`employee_id`), + KEY `ix_attendance_record_employee_no` (`employee_no`), + KEY `ix_attendance_record_employee_name` (`employee_name`), + KEY `ix_attendance_record_work_date` (`work_date`), + KEY `ix_attendance_record_attendance_status` (`attendance_status`), + CONSTRAINT `fk_attendance_record_job_id` + FOREIGN KEY (`job_id`) REFERENCES `payroll_jobs` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_attendance_record_employee_id` + FOREIGN KEY (`employee_id`) REFERENCES `employees` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='考勤记录表,保存标准化后的每日打卡、迟到、缺卡、请假和加班工时'; + +CREATE TABLE IF NOT EXISTS `leave_record` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '请假记录主键ID', + `job_id` VARCHAR(32) NULL COMMENT '关联工资计算任务ID', + `employee_id` INT NULL COMMENT '关联员工ID,未匹配员工时为空', + `employee_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '员工编号快照', + `employee_name` VARCHAR(128) NOT NULL COMMENT '员工姓名快照', + `leave_type` VARCHAR(32) NOT NULL COMMENT '请假类型', + `start_text` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '原始开始时间文本', + `end_text` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '原始结束时间文本', + `hours` FLOAT NOT NULL DEFAULT 0 COMMENT '请假工时', + `source_type` VARCHAR(32) NOT NULL DEFAULT 'excel' COMMENT '来源:excel或dingtalk', + `status` VARCHAR(32) NOT NULL DEFAULT 'approved' COMMENT '状态:approved已生效', + `remark` TEXT NOT NULL COMMENT '备注', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `ix_leave_record_job_id` (`job_id`), + KEY `ix_leave_record_employee_id` (`employee_id`), + KEY `ix_leave_record_employee_no` (`employee_no`), + KEY `ix_leave_record_employee_name` (`employee_name`), + KEY `ix_leave_record_leave_type` (`leave_type`), + CONSTRAINT `fk_leave_record_job_id` + FOREIGN KEY (`job_id`) REFERENCES `payroll_jobs` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_leave_record_employee_id` + FOREIGN KEY (`employee_id`) REFERENCES `employees` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='请假记录表,保存事假、病假、年假、调休等统一工时记录'; + +CREATE TABLE IF NOT EXISTS `overtime_record` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '加班记录主键ID', + `job_id` VARCHAR(32) NULL COMMENT '关联工资计算任务ID', + `employee_id` INT NULL COMMENT '关联员工ID,未匹配员工时为空', + `employee_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '员工编号快照', + `employee_name` VARCHAR(128) NOT NULL COMMENT '员工姓名快照', + `work_date` DATE NOT NULL COMMENT '加班日期', + `overtime_type` VARCHAR(32) NOT NULL DEFAULT 'workday' COMMENT '加班类型:workday工作日、weekend周末、holiday法定节假日', + `hours` FLOAT NOT NULL DEFAULT 0 COMMENT '加班工时', + `rate` FLOAT NOT NULL DEFAULT 0 COMMENT '加班小时单价', + `amount` FLOAT NOT NULL DEFAULT 0 COMMENT '加班费金额', + `source_type` VARCHAR(32) NOT NULL DEFAULT 'excel' COMMENT '来源:excel或dingtalk', + `status` VARCHAR(32) NOT NULL DEFAULT 'calculated' COMMENT '状态:calculated已计算', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `ix_overtime_record_job_id` (`job_id`), + KEY `ix_overtime_record_employee_id` (`employee_id`), + KEY `ix_overtime_record_employee_no` (`employee_no`), + KEY `ix_overtime_record_employee_name` (`employee_name`), + KEY `ix_overtime_record_work_date` (`work_date`), + KEY `ix_overtime_record_overtime_type` (`overtime_type`), + CONSTRAINT `fk_overtime_record_job_id` + FOREIGN KEY (`job_id`) REFERENCES `payroll_jobs` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_overtime_record_employee_id` + FOREIGN KEY (`employee_id`) REFERENCES `employees` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='加班记录表,保存每日加班工时、加班类型、单价和金额'; + +CREATE TABLE IF NOT EXISTS `commission_record` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '提成记录主键ID', + `employee_id` INT NOT NULL COMMENT '关联员工ID', + `commission_month` VARCHAR(7) NOT NULL COMMENT '提成月份,格式YYYY-MM', + `commission_type` VARCHAR(64) NOT NULL DEFAULT '销售提成' COMMENT '提成类型', + `amount` FLOAT NOT NULL DEFAULT 0 COMMENT '提成金额', + `source_type` VARCHAR(32) NOT NULL DEFAULT 'manual' COMMENT '来源:manual手工、excel导入', + `business_ref` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '业务单号或来源标识', + `remark` TEXT NOT NULL COMMENT '备注', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `ix_commission_record_employee_id` (`employee_id`), + KEY `ix_commission_record_commission_month` (`commission_month`), + CONSTRAINT `fk_commission_record_employee_id` + FOREIGN KEY (`employee_id`) REFERENCES `employees` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='提成记录表,保存销售提成、项目奖金、绩效奖励等月度金额'; + +CREATE TABLE IF NOT EXISTS `salary_record` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '工资记录主键ID', + `job_id` VARCHAR(32) NULL COMMENT '关联工资计算任务ID', + `employee_id` INT NULL COMMENT '关联员工ID,未匹配员工时为空', + `employee_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '员工编号快照', + `employee_name` VARCHAR(128) NOT NULL COMMENT '员工姓名快照', + `department` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '部门名称快照', + `salary_month` VARCHAR(7) NOT NULL COMMENT '工资月份,格式YYYY-MM', + `salary_mode` VARCHAR(32) NOT NULL DEFAULT 'monthly' COMMENT '薪资模式快照', + `base_pay` FLOAT NOT NULL DEFAULT 0 COMMENT '基础工资金额', + `commission_amount` FLOAT NOT NULL DEFAULT 0 COMMENT '提成金额', + `overtime_pay` FLOAT NOT NULL DEFAULT 0 COMMENT '加班费金额', + `deduction_amount` FLOAT NOT NULL DEFAULT 0 COMMENT '扣款合计金额', + `gross_salary` FLOAT NOT NULL DEFAULT 0 COMMENT '应发工资', + `net_salary` FLOAT NOT NULL DEFAULT 0 COMMENT '实发工资', + `status` VARCHAR(32) NOT NULL DEFAULT 'calculated' COMMENT '状态:calculated已计算', + `remark` TEXT NOT NULL COMMENT '备注', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `ix_salary_record_job_id` (`job_id`), + KEY `ix_salary_record_employee_id` (`employee_id`), + KEY `ix_salary_record_employee_no` (`employee_no`), + KEY `ix_salary_record_employee_name` (`employee_name`), + KEY `ix_salary_record_department` (`department`), + KEY `ix_salary_record_salary_month` (`salary_month`), + CONSTRAINT `fk_salary_record_job_id` + FOREIGN KEY (`job_id`) REFERENCES `payroll_jobs` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_salary_record_employee_id` + FOREIGN KEY (`employee_id`) REFERENCES `employees` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='工资记录表,保存员工月度工资汇总结果'; + +CREATE TABLE IF NOT EXISTS `salary_detail` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '工资明细主键ID', + `salary_record_id` INT NOT NULL COMMENT '关联工资记录ID', + `item_type` VARCHAR(32) NOT NULL COMMENT '工资项类型:earning收入、deduction扣款、stat统计', + `item_name` VARCHAR(64) NOT NULL COMMENT '工资项名称', + `amount` FLOAT NOT NULL DEFAULT 0 COMMENT '金额', + `quantity` FLOAT NOT NULL DEFAULT 0 COMMENT '数量或工时', + `unit_price` FLOAT NOT NULL DEFAULT 0 COMMENT '单价', + `remark` TEXT NOT NULL COMMENT '备注', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `ix_salary_detail_salary_record_id` (`salary_record_id`), + KEY `ix_salary_detail_item_type` (`item_type`), + CONSTRAINT `fk_salary_detail_salary_record_id` + FOREIGN KEY (`salary_record_id`) REFERENCES `salary_record` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='工资明细表,保存工资记录下的每个工资项'; + +CREATE TABLE IF NOT EXISTS `salary_config` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '配置主键ID', + `config_key` VARCHAR(128) NOT NULL COMMENT '配置键', + `config_name` VARCHAR(128) NOT NULL COMMENT '配置名称', + `config_group` VARCHAR(64) NOT NULL COMMENT '配置分组', + `config_value` TEXT NOT NULL COMMENT '配置值,按value_type解析', + `value_type` VARCHAR(32) NOT NULL DEFAULT 'string' COMMENT '值类型:string、number、integer、boolean、json', + `is_enabled` BOOL NOT NULL DEFAULT TRUE COMMENT '是否启用', + `remark` TEXT NOT NULL COMMENT '配置说明', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `ix_salary_config_config_key` (`config_key`), + KEY `ix_salary_config_config_group` (`config_group`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='系统配置表,保存可后台维护的考勤、薪资和集成规则'; + +INSERT INTO `salary_config` (`config_key`, `config_name`, `config_group`, `config_value`, `value_type`, `is_enabled`, `remark`) VALUES +('attendance.on_work_time', '上班时间', 'attendance', '08:30', 'string', TRUE, '用于迟到识别,格式HH:MM'), +('attendance.off_work_time', '下班时间', 'attendance', '17:00', 'string', TRUE, '加班按下班打卡超过该时间后向下取整'), +('attendance.overtime_round_minutes', '加班取整分钟', 'attendance', '60', 'integer', TRUE, '例如60表示20:30按3小时计算'), +('attendance.standard_daily_hours', '标准日工时', 'attendance', '8', 'number', TRUE, '计时工资按出勤天数折算工时'), +('attendance.minor_late_minutes', '轻微迟到分钟', 'attendance', '5', 'integer', TRUE, '小于等于该分钟数按轻微迟到统计'), +('attendance.minor_late_free_times', '轻微迟到免扣次数', 'attendance', '3', 'integer', TRUE, '5分钟内迟到3次内合格,超过后按次扣款'), +('attendance.late_penalty', '迟到扣款', 'attendance', '30', 'number', TRUE, '每次计扣迟到扣款金额'), +('attendance.missing_card_penalty', '缺卡扣款', 'attendance', '20', 'number', TRUE, '每次缺卡扣款金额'), +('attendance.weekend_days', '周末星期', 'attendance', '[5, 6]', 'json', TRUE, 'Python weekday:周一0,周六5,周日6'), +('attendance.legal_holidays', '法定节假日', 'attendance', '[]', 'json', TRUE, '日期数组,例如["2026-10-01"]'), +('attendance.leave_keywords', '请假关键字', 'attendance', '["调休", "请假", "事假", "病假", "年假", "婚假", "产假", "陪产假", "丧假"]', 'json', TRUE, '从钉钉单元格识别请假/调休类型'), +('payroll.default_overtime_rate', '工作日加班单价', 'payroll', '0', 'number', TRUE, '未维护员工单价时使用'), +('payroll.default_weekend_overtime_rate', '周末加班单价', 'payroll', '0', 'number', TRUE, '未维护员工周末单价时使用'), +('payroll.default_holiday_overtime_rate', '节假日加班单价', 'payroll', '0', 'number', TRUE, '未维护员工节假日单价时使用') +ON DUPLICATE KEY UPDATE `config_key` = VALUES(`config_key`); diff --git a/financial_system/database/sql/seed.sql b/financial_system/database/sql/seed.sql new file mode 100644 index 0000000..84646dd --- /dev/null +++ b/financial_system/database/sql/seed.sql @@ -0,0 +1,41 @@ +-- Financial System seed data. +-- Default superuser: +-- username: admin +-- password: Admin@123456 +-- Change the password after first login in production. + +USE `financial_system`; + +INSERT INTO `users` ( + `username`, + `display_name`, + `avatar_url`, + `email`, + `phone`, + `department`, + `position_title`, + `password_hash`, + `role`, + `is_active`, + `created_at`, + `updated_at` +) VALUES ( + 'admin', + '超级管理员', + '', + '', + '', + '系统管理', + '超级管理员', + 'pbkdf2_sha256$260000$b3a9c1TDdorYhXOj4usDsA$BPH38v3hjJ-vj8tGw-senDM5czyhRX-wr_zS8lV4bBo', + 'superuser', + TRUE, + NOW(), + NOW() +) ON DUPLICATE KEY UPDATE + `display_name` = VALUES(`display_name`), + `department` = VALUES(`department`), + `position_title` = VALUES(`position_title`), + `role` = VALUES(`role`), + `is_active` = VALUES(`is_active`), + `updated_at` = NOW(); diff --git a/financial_system/database/sql/upgrade_20260616_employee_salary.sql b/financial_system/database/sql/upgrade_20260616_employee_salary.sql new file mode 100644 index 0000000..9a61634 --- /dev/null +++ b/financial_system/database/sql/upgrade_20260616_employee_salary.sql @@ -0,0 +1,55 @@ +-- Add employee and salary maintenance tables to an existing financial_system database. +-- Execute with: +-- mysql -h localhost -P 3306 -u root -p12345678 < financial_system/database/sql/upgrade_20260616_employee_salary.sql + +USE `financial_system`; + +CREATE TABLE IF NOT EXISTS `employees` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '员工主键ID', + `employee_no` VARCHAR(64) NOT NULL COMMENT '员工编号,全局唯一,系统自动生成,默认ZA前缀', + `name` VARCHAR(128) NOT NULL COMMENT '员工姓名', + `dingtalk_user_id` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '钉钉用户ID,用于对接钉钉考勤', + `department` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '部门名称', + `position` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '岗位名称', + `phone` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '手机号', + `employment_status` VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '员工状态:active在职、inactive离职', + `remark` TEXT NOT NULL COMMENT '员工备注', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `ix_employees_employee_no` (`employee_no`), + KEY `ix_employees_name` (`name`), + KEY `ix_employees_dingtalk_user_id` (`dingtalk_user_id`), + KEY `ix_employees_employment_status` (`employment_status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='员工基础档案表,维护员工编号、姓名、部门、岗位和钉钉用户ID'; + +CREATE TABLE IF NOT EXISTS `salary_profiles` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '薪资档案主键ID', + `employee_id` INT NOT NULL COMMENT '关联员工ID,一名员工一条当前薪资档案', + `base_salary` FLOAT NOT NULL DEFAULT 0 COMMENT '基础工资或底薪', + `commission_amount` FLOAT NOT NULL DEFAULT 0 COMMENT '手工维护的提成费用', + `overtime_rate` FLOAT NOT NULL DEFAULT 0 COMMENT '加班费小时单价,独立于基础工资维护', + `effective_month` VARCHAR(7) NOT NULL DEFAULT '' COMMENT '生效月份,格式YYYY-MM;为空表示长期当前档案', + `remark` TEXT NOT NULL COMMENT '薪资备注', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `ix_salary_profiles_employee_id` (`employee_id`), + CONSTRAINT `fk_salary_profiles_employee_id` + FOREIGN KEY (`employee_id`) REFERENCES `employees` (`id`) + ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='员工薪资档案表,维护底薪、手工提成和独立加班费单价'; + +SET @has_payroll_commission_amount := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'payroll_results' AND COLUMN_NAME = 'commission_amount' +); +SET @ddl := IF(@has_payroll_commission_amount = 0, + 'ALTER TABLE `payroll_results` ADD COLUMN `commission_amount` FLOAT NOT NULL DEFAULT 0 COMMENT ''手工维护的提成费用'' AFTER `base_salary`', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/financial_system/database/sql/upgrade_20260616_operation_logs.sql b/financial_system/database/sql/upgrade_20260616_operation_logs.sql new file mode 100644 index 0000000..847f052 --- /dev/null +++ b/financial_system/database/sql/upgrade_20260616_operation_logs.sql @@ -0,0 +1,32 @@ +-- Add operation log table to an existing financial_system database. +-- Execute with: +-- mysql -h localhost -P 3306 -u root -p12345678 < financial_system/database/sql/upgrade_20260616_operation_logs.sql + +USE `financial_system`; + +CREATE TABLE IF NOT EXISTS `operation_logs` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '日志主键ID', + `user_id` INT NULL COMMENT '操作用户ID,匿名或用户删除后为空', + `username` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '操作用户名快照', + `role` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '操作用户角色快照', + `module` VARCHAR(64) NOT NULL COMMENT '业务模块,例如auth、payroll、system', + `action` VARCHAR(128) NOT NULL COMMENT '操作动作编码,例如login、payroll.excel.calculate', + `target_type` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '操作对象类型,例如user、payroll_job、file', + `target_id` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '操作对象ID或业务标识', + `status` VARCHAR(32) NOT NULL COMMENT '操作状态:success或failed', + `detail` TEXT NOT NULL COMMENT '操作详情或失败原因', + `ip_address` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '客户端IP地址', + `user_agent` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '客户端浏览器User-Agent', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间', + PRIMARY KEY (`id`), + KEY `ix_operation_logs_user_id` (`user_id`), + KEY `ix_operation_logs_username` (`username`), + KEY `ix_operation_logs_module` (`module`), + KEY `ix_operation_logs_action` (`action`), + KEY `ix_operation_logs_status` (`status`), + KEY `ix_operation_logs_created_at` (`created_at`), + CONSTRAINT `fk_operation_logs_user_id` + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) + ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='系统操作日志表,记录用户登录、资料维护、工资计算、下载和系统管理动作'; diff --git a/financial_system/database/sql/upgrade_20260616_user_profile.sql b/financial_system/database/sql/upgrade_20260616_user_profile.sql new file mode 100644 index 0000000..7a3b12b --- /dev/null +++ b/financial_system/database/sql/upgrade_20260616_user_profile.sql @@ -0,0 +1,71 @@ +-- Add user profile and avatar fields to an existing financial_system database. +-- Execute with: +-- mysql -h localhost -P 3306 -u root -p12345678 < financial_system/database/sql/upgrade_20260616_user_profile.sql + +USE `financial_system`; + +SET @has_avatar_url := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'avatar_url' +); +SET @ddl := IF(@has_avatar_url = 0, + 'ALTER TABLE `users` ADD COLUMN `avatar_url` VARCHAR(512) NOT NULL DEFAULT '''' COMMENT ''用户头像URL或静态资源路径''', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @has_email := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'email' +); +SET @ddl := IF(@has_email = 0, + 'ALTER TABLE `users` ADD COLUMN `email` VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''用户邮箱''', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @has_phone := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'phone' +); +SET @ddl := IF(@has_phone = 0, + 'ALTER TABLE `users` ADD COLUMN `phone` VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''用户手机号''', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @has_department := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'department' +); +SET @ddl := IF(@has_department = 0, + 'ALTER TABLE `users` ADD COLUMN `department` VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''用户所属部门''', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @has_position_title := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'position_title' +); +SET @ddl := IF(@has_position_title = 0, + 'ALTER TABLE `users` ADD COLUMN `position_title` VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''用户职位或岗位名称''', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +UPDATE `users` +SET + `department` = IF(`department` = '', '系统管理', `department`), + `position_title` = IF(`position_title` = '', '超级管理员', `position_title`) +WHERE `username` = 'admin'; diff --git a/financial_system/database/sql/upgrade_20260617_payroll_modules.sql b/financial_system/database/sql/upgrade_20260617_payroll_modules.sql new file mode 100644 index 0000000..cb16c05 --- /dev/null +++ b/financial_system/database/sql/upgrade_20260617_payroll_modules.sql @@ -0,0 +1,362 @@ +-- 2026-06-17 薪酬考勤完整模块升级脚本。 +-- Execute with: +-- mysql -h localhost -P 3306 -u root -p12345678 financial_system < financial_system/database/sql/upgrade_20260617_payroll_modules.sql + +USE `financial_system`; + +CREATE TABLE IF NOT EXISTS `departments` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '部门主键ID', + `parent_id` INT NULL COMMENT '父级部门ID,顶级部门为空', + `department_code` VARCHAR(64) NOT NULL COMMENT '部门编码,全局唯一', + `name` VARCHAR(128) NOT NULL COMMENT '部门名称', + `sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序号,越小越靠前', + `is_active` BOOL NOT NULL DEFAULT TRUE COMMENT '是否启用', + `remark` TEXT NOT NULL COMMENT '部门备注', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `ix_departments_department_code` (`department_code`), + UNIQUE KEY `ix_departments_name` (`name`), + KEY `ix_departments_parent_id` (`parent_id`), + KEY `ix_departments_is_active` (`is_active`), + CONSTRAINT `fk_departments_parent_id` FOREIGN KEY (`parent_id`) REFERENCES `departments` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='部门表,维护组织架构中的部门及上下级关系'; + +CREATE TABLE IF NOT EXISTS `positions` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '岗位主键ID', + `department_id` INT NOT NULL COMMENT '关联部门ID', + `position_code` VARCHAR(64) NOT NULL COMMENT '岗位编码,全局唯一', + `name` VARCHAR(128) NOT NULL COMMENT '岗位名称', + `sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序号,越小越靠前', + `is_active` BOOL NOT NULL DEFAULT TRUE COMMENT '是否启用', + `remark` TEXT NOT NULL COMMENT '岗位备注', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `ix_positions_position_code` (`position_code`), + UNIQUE KEY `uq_positions_department_name` (`department_id`, `name`), + KEY `ix_positions_department_id` (`department_id`), + KEY `ix_positions_name` (`name`), + KEY `ix_positions_is_active` (`is_active`), + CONSTRAINT `fk_positions_department_id` FOREIGN KEY (`department_id`) REFERENCES `departments` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='岗位表,维护部门下的职位/岗位'; + +SET @has_position_department_name_index := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'positions' + AND INDEX_NAME = 'uq_positions_department_name' +); +SET @ddl := IF(@has_position_department_name_index = 0, + 'ALTER TABLE `positions` ADD UNIQUE KEY `uq_positions_department_name` (`department_id`, `name`)', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +DROP TEMPORARY TABLE IF EXISTS `tmp_organization_departments`; +CREATE TEMPORARY TABLE `tmp_organization_departments` ( + `department_code` VARCHAR(64) NOT NULL, + `name` VARCHAR(128) NOT NULL, + `parent_name` VARCHAR(128) NULL, + `sort_order` INT NOT NULL, + `remark` VARCHAR(255) NOT NULL +) ENGINE=MEMORY; + +INSERT INTO `tmp_organization_departments` +(`department_code`, `name`, `parent_name`, `sort_order`, `remark`) VALUES +('ORG001', '总经办', NULL, 1, '系统初始化部门'), +('ORG002', '行政人事部', NULL, 2, '系统初始化部门'), +('ORG003', '财务部', NULL, 3, '系统初始化部门'), +('ORG004', '订单部', NULL, 4, '系统初始化部门'), +('ORG005', '工程部', NULL, 5, '系统根据工程部子部门自动补齐的父级部门'), +('ORG006', '工程部-安装队', '工程部', 6, '系统初始化部门'), +('ORG007', '工程部-项目预算', '工程部', 7, '系统初始化部门'), +('ORG008', '杭州运营中心', NULL, 8, '系统根据杭州运营中心子部门自动补齐的父级部门'), +('ORG009', '杭州运营中心-销售部', '杭州运营中心', 9, '系统初始化部门'), +('ORG010', '华南运营中心', NULL, 10, '系统初始化部门'), +('ORG011', '华中运营中心', NULL, 11, '系统初始化部门'), +('ORG012', '生产中心', NULL, 12, '系统初始化部门'), +('ORG013', '生产中心-采购部', '生产中心', 13, '系统初始化部门'), +('ORG014', '生产中心-仓库', '生产中心', 14, '系统初始化部门'), +('ORG015', '生产中心-品管部', '生产中心', 15, '系统初始化部门'), +('ORG016', '生产中心-生产部', '生产中心', 16, '系统初始化部门'), +('ORG017', '西南运营中心', NULL, 17, '系统初始化部门'), +('ORG018', '西南运营中心-办事处', '西南运营中心', 18, '系统初始化部门'), +('ORG019', '西南运营中心-销售部', '西南运营中心', 19, '系统初始化部门'); + +INSERT INTO `departments` (`department_code`, `name`, `sort_order`, `is_active`, `remark`) +SELECT `department_code`, `name`, `sort_order`, TRUE, `remark` +FROM `tmp_organization_departments` +ON DUPLICATE KEY UPDATE + `department_code` = VALUES(`department_code`), + `sort_order` = VALUES(`sort_order`), + `is_active` = VALUES(`is_active`), + `remark` = VALUES(`remark`); + +UPDATE `departments` child +JOIN `tmp_organization_departments` seed ON seed.name = child.name +LEFT JOIN `departments` parent ON parent.name = seed.parent_name +SET child.parent_id = parent.id; + +DROP TEMPORARY TABLE IF EXISTS `tmp_organization_positions`; +CREATE TEMPORARY TABLE `tmp_organization_positions` ( + `department_name` VARCHAR(128) NOT NULL, + `position_code` VARCHAR(64) NOT NULL, + `name` VARCHAR(128) NOT NULL, + `sort_order` INT NOT NULL +) ENGINE=MEMORY; + +INSERT INTO `tmp_organization_positions` +(`department_name`, `position_code`, `name`, `sort_order`) VALUES +('总经办', 'POS001', '总经理', 1), +('总经办', 'POS002', '副总经理', 2), +('总经办', 'POS003', '总经理助理', 3), +('行政人事部', 'POS004', '人事经理', 1), +('行政人事部', 'POS005', '人事专员', 2), +('财务部', 'POS006', '财务', 1), +('财务部', 'POS007', '出纳专员', 2), +('订单部', 'POS008', '销售助理', 1), +('订单部', 'POS009', '订单助理', 2), +('工程部-安装队', 'POS010', '经理助理', 1), +('工程部-安装队', 'POS011', '安装师傅', 2), +('工程部-安装队', 'POS012', '项目经理', 3), +('工程部-项目预算', 'POS013', '项目预算', 1), +('杭州运营中心-销售部', 'POS014', '销售总监', 1), +('杭州运营中心-销售部', 'POS015', '销售', 2), +('华南运营中心', 'POS016', '销售总监', 1), +('华中运营中心', 'POS017', '销售总监', 1), +('生产中心', 'POS018', '机械工程师', 1), +('生产中心-采购部', 'POS019', '采购', 1), +('生产中心-仓库', 'POS020', '仓库', 1), +('生产中心-仓库', 'POS021', '仓库物料员', 2), +('生产中心-品管部', 'POS022', '品管', 1), +('生产中心-生产部', 'POS023', '焊接师傅', 1), +('生产中心-生产部', 'POS024', '普工', 2), +('生产中心-生产部', 'POS025', '技工', 3), +('西南运营中心', 'POS026', '销售总监', 1), +('西南运营中心-办事处', 'POS027', '经理助理', 1), +('西南运营中心-销售部', 'POS028', '销售工程师', 1); + +INSERT INTO `positions` (`department_id`, `position_code`, `name`, `sort_order`, `is_active`, `remark`) +SELECT d.id, p.position_code, p.name, p.sort_order, TRUE, '系统初始化岗位' +FROM `tmp_organization_positions` p +JOIN `departments` d ON d.name = p.department_name +ON DUPLICATE KEY UPDATE + `department_id` = VALUES(`department_id`), + `position_code` = VALUES(`position_code`), + `name` = VALUES(`name`), + `sort_order` = VALUES(`sort_order`), + `is_active` = VALUES(`is_active`), + `remark` = VALUES(`remark`); + +DROP TEMPORARY TABLE IF EXISTS `tmp_organization_positions`; +DROP TEMPORARY TABLE IF EXISTS `tmp_organization_departments`; + +ALTER TABLE `salary_profiles` + ADD COLUMN IF NOT EXISTS `salary_mode` VARCHAR(32) NOT NULL DEFAULT 'monthly' COMMENT '薪资模式:monthly包月、hourly计时、piecework计件、probation试用期' AFTER `employee_id`, + ADD COLUMN IF NOT EXISTS `hourly_rate` FLOAT NOT NULL DEFAULT 0 COMMENT '计时工资小时单价' AFTER `base_salary`, + ADD COLUMN IF NOT EXISTS `piece_quantity` FLOAT NOT NULL DEFAULT 0 COMMENT '计件数量' AFTER `hourly_rate`, + ADD COLUMN IF NOT EXISTS `piece_unit_price` FLOAT NOT NULL DEFAULT 0 COMMENT '计件单价' AFTER `piece_quantity`, + ADD COLUMN IF NOT EXISTS `probation_type` VARCHAR(32) NOT NULL DEFAULT 'ratio' COMMENT '试用期计算方式:ratio固定比例、fixed固定金额' AFTER `piece_unit_price`, + ADD COLUMN IF NOT EXISTS `probation_ratio` FLOAT NOT NULL DEFAULT 1 COMMENT '试用期工资比例' AFTER `probation_type`, + ADD COLUMN IF NOT EXISTS `probation_salary` FLOAT NOT NULL DEFAULT 0 COMMENT '试用期固定工资' AFTER `probation_ratio`, + ADD COLUMN IF NOT EXISTS `weekend_overtime_rate` FLOAT NOT NULL DEFAULT 0 COMMENT '周末加班费小时单价' AFTER `overtime_rate`, + ADD COLUMN IF NOT EXISTS `holiday_overtime_rate` FLOAT NOT NULL DEFAULT 0 COMMENT '法定节假日加班费小时单价' AFTER `weekend_overtime_rate`; + +ALTER TABLE `payroll_results` + ADD COLUMN IF NOT EXISTS `salary_month` VARCHAR(7) NOT NULL DEFAULT '' COMMENT '工资月份,格式YYYY-MM' AFTER `attendance_group`, + ADD COLUMN IF NOT EXISTS `salary_mode` VARCHAR(32) NOT NULL DEFAULT 'monthly' COMMENT '薪资模式快照' AFTER `salary_month`, + ADD COLUMN IF NOT EXISTS `attendance_days` FLOAT NOT NULL DEFAULT 0 COMMENT '出勤天数' AFTER `salary_mode`, + ADD COLUMN IF NOT EXISTS `absence_days` FLOAT NOT NULL DEFAULT 0 COMMENT '缺勤天数' AFTER `attendance_days`, + ADD COLUMN IF NOT EXISTS `actual_work_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '实际工作工时' AFTER `absence_days`, + ADD COLUMN IF NOT EXISTS `workday_overtime_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '工作日剩余可计费加班工时' AFTER `remaining_overtime_hours`, + ADD COLUMN IF NOT EXISTS `weekend_overtime_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '周末剩余可计费加班工时' AFTER `workday_overtime_hours`, + ADD COLUMN IF NOT EXISTS `holiday_overtime_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '法定节假日剩余可计费加班工时' AFTER `weekend_overtime_hours`, + ADD COLUMN IF NOT EXISTS `base_pay` FLOAT NULL COMMENT '按薪资模式计算后的基础工资' AFTER `base_salary`, + ADD COLUMN IF NOT EXISTS `weekend_overtime_rate` FLOAT NOT NULL DEFAULT 0 COMMENT '周末加班小时单价' AFTER `overtime_rate`, + ADD COLUMN IF NOT EXISTS `holiday_overtime_rate` FLOAT NOT NULL DEFAULT 0 COMMENT '法定节假日加班小时单价' AFTER `weekend_overtime_rate`; + +CREATE TABLE IF NOT EXISTS `attendance_record` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '考勤记录主键ID', + `job_id` VARCHAR(32) NULL COMMENT '关联工资计算任务ID', + `employee_id` INT NULL COMMENT '关联员工ID,未匹配员工时为空', + `employee_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '员工编号快照', + `employee_name` VARCHAR(128) NOT NULL COMMENT '员工姓名快照', + `department` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '部门名称快照', + `work_date` DATE NOT NULL COMMENT '考勤日期', + `first_punch` VARCHAR(5) NOT NULL DEFAULT '' COMMENT '上班打卡时间HH:MM', + `last_punch` VARCHAR(5) NOT NULL DEFAULT '' COMMENT '下班打卡时间HH:MM', + `attendance_status` VARCHAR(32) NOT NULL DEFAULT 'present' COMMENT '考勤状态:present出勤、absent缺勤、leave请假、missing缺卡', + `late_minutes` INT NOT NULL DEFAULT 0 COMMENT '当日迟到分钟合计', + `missing_card_count` INT NOT NULL DEFAULT 0 COMMENT '当日缺卡次数', + `leave_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '当日请假或调休工时', + `overtime_hours` FLOAT NOT NULL DEFAULT 0 COMMENT '当日打卡推算加班工时', + `raw_text` TEXT NOT NULL COMMENT '原始考勤单元格内容', + `source_type` VARCHAR(32) NOT NULL DEFAULT 'excel' COMMENT '来源:excel或dingtalk', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `ix_attendance_record_job_id` (`job_id`), + KEY `ix_attendance_record_employee_id` (`employee_id`), + KEY `ix_attendance_record_employee_no` (`employee_no`), + KEY `ix_attendance_record_employee_name` (`employee_name`), + KEY `ix_attendance_record_work_date` (`work_date`), + KEY `ix_attendance_record_attendance_status` (`attendance_status`), + CONSTRAINT `fk_attendance_record_job_id` FOREIGN KEY (`job_id`) REFERENCES `payroll_jobs` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_attendance_record_employee_id` FOREIGN KEY (`employee_id`) REFERENCES `employees` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='考勤记录表,保存标准化后的每日打卡、迟到、缺卡、请假和加班工时'; + +CREATE TABLE IF NOT EXISTS `leave_record` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '请假记录主键ID', + `job_id` VARCHAR(32) NULL COMMENT '关联工资计算任务ID', + `employee_id` INT NULL COMMENT '关联员工ID,未匹配员工时为空', + `employee_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '员工编号快照', + `employee_name` VARCHAR(128) NOT NULL COMMENT '员工姓名快照', + `leave_type` VARCHAR(32) NOT NULL COMMENT '请假类型', + `start_text` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '原始开始时间文本', + `end_text` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '原始结束时间文本', + `hours` FLOAT NOT NULL DEFAULT 0 COMMENT '请假工时', + `source_type` VARCHAR(32) NOT NULL DEFAULT 'excel' COMMENT '来源:excel或dingtalk', + `status` VARCHAR(32) NOT NULL DEFAULT 'approved' COMMENT '状态:approved已生效', + `remark` TEXT NOT NULL COMMENT '备注', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `ix_leave_record_job_id` (`job_id`), + KEY `ix_leave_record_employee_id` (`employee_id`), + KEY `ix_leave_record_employee_no` (`employee_no`), + KEY `ix_leave_record_employee_name` (`employee_name`), + KEY `ix_leave_record_leave_type` (`leave_type`), + CONSTRAINT `fk_leave_record_job_id` FOREIGN KEY (`job_id`) REFERENCES `payroll_jobs` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_leave_record_employee_id` FOREIGN KEY (`employee_id`) REFERENCES `employees` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='请假记录表,保存事假、病假、年假、调休等统一工时记录'; + +CREATE TABLE IF NOT EXISTS `overtime_record` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '加班记录主键ID', + `job_id` VARCHAR(32) NULL COMMENT '关联工资计算任务ID', + `employee_id` INT NULL COMMENT '关联员工ID,未匹配员工时为空', + `employee_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '员工编号快照', + `employee_name` VARCHAR(128) NOT NULL COMMENT '员工姓名快照', + `work_date` DATE NOT NULL COMMENT '加班日期', + `overtime_type` VARCHAR(32) NOT NULL DEFAULT 'workday' COMMENT '加班类型:workday工作日、weekend周末、holiday法定节假日', + `hours` FLOAT NOT NULL DEFAULT 0 COMMENT '加班工时', + `rate` FLOAT NOT NULL DEFAULT 0 COMMENT '加班小时单价', + `amount` FLOAT NOT NULL DEFAULT 0 COMMENT '加班费金额', + `source_type` VARCHAR(32) NOT NULL DEFAULT 'excel' COMMENT '来源:excel或dingtalk', + `status` VARCHAR(32) NOT NULL DEFAULT 'calculated' COMMENT '状态:calculated已计算', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `ix_overtime_record_job_id` (`job_id`), + KEY `ix_overtime_record_employee_id` (`employee_id`), + KEY `ix_overtime_record_employee_no` (`employee_no`), + KEY `ix_overtime_record_employee_name` (`employee_name`), + KEY `ix_overtime_record_work_date` (`work_date`), + KEY `ix_overtime_record_overtime_type` (`overtime_type`), + CONSTRAINT `fk_overtime_record_job_id` FOREIGN KEY (`job_id`) REFERENCES `payroll_jobs` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_overtime_record_employee_id` FOREIGN KEY (`employee_id`) REFERENCES `employees` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='加班记录表,保存每日加班工时、加班类型、单价和金额'; + +CREATE TABLE IF NOT EXISTS `commission_record` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '提成记录主键ID', + `employee_id` INT NOT NULL COMMENT '关联员工ID', + `commission_month` VARCHAR(7) NOT NULL COMMENT '提成月份,格式YYYY-MM', + `commission_type` VARCHAR(64) NOT NULL DEFAULT '销售提成' COMMENT '提成类型', + `amount` FLOAT NOT NULL DEFAULT 0 COMMENT '提成金额', + `source_type` VARCHAR(32) NOT NULL DEFAULT 'manual' COMMENT '来源:manual手工、excel导入', + `business_ref` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '业务单号或来源标识', + `remark` TEXT NOT NULL COMMENT '备注', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `ix_commission_record_employee_id` (`employee_id`), + KEY `ix_commission_record_commission_month` (`commission_month`), + CONSTRAINT `fk_commission_record_employee_id` FOREIGN KEY (`employee_id`) REFERENCES `employees` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='提成记录表,保存销售提成、项目奖金、绩效奖励等月度金额'; + +CREATE TABLE IF NOT EXISTS `salary_record` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '工资记录主键ID', + `job_id` VARCHAR(32) NULL COMMENT '关联工资计算任务ID', + `employee_id` INT NULL COMMENT '关联员工ID,未匹配员工时为空', + `employee_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '员工编号快照', + `employee_name` VARCHAR(128) NOT NULL COMMENT '员工姓名快照', + `department` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '部门名称快照', + `salary_month` VARCHAR(7) NOT NULL COMMENT '工资月份,格式YYYY-MM', + `salary_mode` VARCHAR(32) NOT NULL DEFAULT 'monthly' COMMENT '薪资模式快照', + `base_pay` FLOAT NOT NULL DEFAULT 0 COMMENT '基础工资金额', + `commission_amount` FLOAT NOT NULL DEFAULT 0 COMMENT '提成金额', + `overtime_pay` FLOAT NOT NULL DEFAULT 0 COMMENT '加班费金额', + `deduction_amount` FLOAT NOT NULL DEFAULT 0 COMMENT '扣款合计金额', + `gross_salary` FLOAT NOT NULL DEFAULT 0 COMMENT '应发工资', + `net_salary` FLOAT NOT NULL DEFAULT 0 COMMENT '实发工资', + `status` VARCHAR(32) NOT NULL DEFAULT 'calculated' COMMENT '状态:calculated已计算', + `remark` TEXT NOT NULL COMMENT '备注', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `ix_salary_record_job_id` (`job_id`), + KEY `ix_salary_record_employee_id` (`employee_id`), + KEY `ix_salary_record_employee_no` (`employee_no`), + KEY `ix_salary_record_employee_name` (`employee_name`), + KEY `ix_salary_record_department` (`department`), + KEY `ix_salary_record_salary_month` (`salary_month`), + CONSTRAINT `fk_salary_record_job_id` FOREIGN KEY (`job_id`) REFERENCES `payroll_jobs` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_salary_record_employee_id` FOREIGN KEY (`employee_id`) REFERENCES `employees` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='工资记录表,保存员工月度工资汇总结果'; + +CREATE TABLE IF NOT EXISTS `salary_detail` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '工资明细主键ID', + `salary_record_id` INT NOT NULL COMMENT '关联工资记录ID', + `item_type` VARCHAR(32) NOT NULL COMMENT '工资项类型:earning收入、deduction扣款、stat统计', + `item_name` VARCHAR(64) NOT NULL COMMENT '工资项名称', + `amount` FLOAT NOT NULL DEFAULT 0 COMMENT '金额', + `quantity` FLOAT NOT NULL DEFAULT 0 COMMENT '数量或工时', + `unit_price` FLOAT NOT NULL DEFAULT 0 COMMENT '单价', + `remark` TEXT NOT NULL COMMENT '备注', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `ix_salary_detail_salary_record_id` (`salary_record_id`), + KEY `ix_salary_detail_item_type` (`item_type`), + CONSTRAINT `fk_salary_detail_salary_record_id` FOREIGN KEY (`salary_record_id`) REFERENCES `salary_record` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='工资明细表,保存工资记录下的每个工资项'; + +CREATE TABLE IF NOT EXISTS `salary_config` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '配置主键ID', + `config_key` VARCHAR(128) NOT NULL COMMENT '配置键', + `config_name` VARCHAR(128) NOT NULL COMMENT '配置名称', + `config_group` VARCHAR(64) NOT NULL COMMENT '配置分组', + `config_value` TEXT NOT NULL COMMENT '配置值,按value_type解析', + `value_type` VARCHAR(32) NOT NULL DEFAULT 'string' COMMENT '值类型:string、number、integer、boolean、json', + `is_enabled` BOOL NOT NULL DEFAULT TRUE COMMENT '是否启用', + `remark` TEXT NOT NULL COMMENT '配置说明', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `ix_salary_config_config_key` (`config_key`), + KEY `ix_salary_config_config_group` (`config_group`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='系统配置表,保存可后台维护的考勤、薪资和集成规则'; + +INSERT INTO `salary_config` (`config_key`, `config_name`, `config_group`, `config_value`, `value_type`, `is_enabled`, `remark`) VALUES +('attendance.on_work_time', '上班时间', 'attendance', '08:30', 'string', TRUE, '用于迟到识别,格式HH:MM'), +('attendance.off_work_time', '下班时间', 'attendance', '17:00', 'string', TRUE, '加班按下班打卡超过该时间后向下取整'), +('attendance.overtime_round_minutes', '加班取整分钟', 'attendance', '60', 'integer', TRUE, '例如60表示20:30按3小时计算'), +('attendance.standard_daily_hours', '标准日工时', 'attendance', '8', 'number', TRUE, '计时工资按出勤天数折算工时'), +('attendance.minor_late_minutes', '轻微迟到分钟', 'attendance', '5', 'integer', TRUE, '小于等于该分钟数按轻微迟到统计'), +('attendance.minor_late_free_times', '轻微迟到免扣次数', 'attendance', '3', 'integer', TRUE, '5分钟内迟到3次内合格,超过后按次扣款'), +('attendance.late_penalty', '迟到扣款', 'attendance', '30', 'number', TRUE, '每次计扣迟到扣款金额'), +('attendance.missing_card_penalty', '缺卡扣款', 'attendance', '20', 'number', TRUE, '每次缺卡扣款金额'), +('attendance.weekend_days', '周末星期', 'attendance', '[5, 6]', 'json', TRUE, 'Python weekday:周一0,周六5,周日6'), +('attendance.legal_holidays', '法定节假日', 'attendance', '[]', 'json', TRUE, '日期数组,例如["2026-10-01"]'), +('attendance.leave_keywords', '请假关键字', 'attendance', '["调休", "请假", "事假", "病假", "年假", "婚假", "产假", "陪产假", "丧假"]', 'json', TRUE, '从钉钉单元格识别请假/调休类型'), +('payroll.default_overtime_rate', '工作日加班单价', 'payroll', '0', 'number', TRUE, '未维护员工单价时使用'), +('payroll.default_weekend_overtime_rate', '周末加班单价', 'payroll', '0', 'number', TRUE, '未维护员工周末单价时使用'), +('payroll.default_holiday_overtime_rate', '节假日加班单价', 'payroll', '0', 'number', TRUE, '未维护员工节假日单价时使用') +ON DUPLICATE KEY UPDATE `config_key` = VALUES(`config_key`); diff --git a/financial_system/domain/__init__.py b/financial_system/domain/__init__.py new file mode 100644 index 0000000..1193077 --- /dev/null +++ b/financial_system/domain/__init__.py @@ -0,0 +1 @@ +"""Domain models and payroll calculation rules.""" diff --git a/financial_system/domain/calculator.py b/financial_system/domain/calculator.py new file mode 100644 index 0000000..187826f --- /dev/null +++ b/financial_system/domain/calculator.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +from datetime import datetime, time + +from ..core.config import AppConfig +from .models import DailyAttendance, EmployeeAttendance, PayrollResult + + +class PayrollCalculator: + """工资规则计算器,不处理 Excel、HTTP、数据库等外部细节。""" + + def __init__(self, config: AppConfig): + self.config = config + self.off_work_time = datetime.strptime(config.attendance.off_work_time, "%H:%M").time() + + def calculate(self, employees: list[EmployeeAttendance]) -> list[PayrollResult]: + """先按每日下班打卡推算加班,再按员工聚合扣款和工资。""" + for employee in employees: + for record in employee.daily_records: + record.punch_overtime_hours = self._overtime_from_checkout(record) + return [self._calculate_employee(employee) for employee in employees] + + def _calculate_employee(self, employee: EmployeeAttendance) -> PayrollResult: + punch_overtime_hours = round(sum(r.punch_overtime_hours for r in employee.daily_records), 2) + leave_hours = round(sum(event.hours for event in employee.unique_leave_events), 2) + remaining_overtime_hours = round(punch_overtime_hours - leave_hours, 2) + salary_month = _salary_month(employee) + attendance_days = round( + _number(employee.summary_fields.get("出勤天数")) or _count_attendance_days(employee), + 2, + ) + expected_work_days = _expected_work_days(employee, self.config.attendance.weekend_days) + absence_days = round( + _number(employee.summary_fields.get("缺勤天数")) or max(0.0, expected_work_days - attendance_days), + 2, + ) + actual_work_hours = round(attendance_days * self.config.attendance.standard_daily_hours, 2) + raw_workday_hours, raw_weekend_hours, raw_holiday_hours = self._split_overtime_hours(employee) + workday_overtime_hours, weekend_overtime_hours, holiday_overtime_hours = _allocate_payable_overtime( + raw_workday_hours, + raw_weekend_hours, + raw_holiday_hours, + leave_hours, + ) + + # 5 分钟内迟到先累计免扣次数,超过免扣次数后按次扣款。 + late_minutes = [minutes for record in employee.daily_records for minutes in record.late_minutes] + minor_late_count = sum(1 for minutes in late_minutes if minutes <= self.config.attendance.minor_late_minutes) + major_late_count = sum(1 for minutes in late_minutes if minutes > self.config.attendance.minor_late_minutes) + penalized_minor_count = max(0, minor_late_count - self.config.attendance.minor_late_free_times) + penalized_late_count = penalized_minor_count + major_late_count + late_deduction = round(penalized_late_count * self.config.attendance.late_penalty, 2) + + daily_missing_count = sum(record.missing_card_count for record in employee.daily_records) + summary_missing_count = int( + _number(employee.summary_fields.get("上班缺卡次数")) + + _number(employee.summary_fields.get("下班缺卡次数")) + ) + # 汇总列和每日明细都可能记录缺卡,取较大值避免漏扣。 + missing_card_count = max(daily_missing_count, summary_missing_count) + missing_card_deduction = round( + missing_card_count * self.config.attendance.missing_card_penalty, 2 + ) + total_deduction = round(late_deduction + missing_card_deduction, 2) + + pay_config = self.config.payroll.employees.get(employee.name) + salary_mode = pay_config.salary_mode if pay_config else "monthly" + base_salary = pay_config.base_salary if pay_config else None + base_pay = _base_pay(pay_config, actual_work_hours) + overtime_rate = ( + pay_config.overtime_rate + if pay_config and pay_config.overtime_rate is not None + else self.config.payroll.default_overtime_rate + ) + weekend_overtime_rate = ( + pay_config.weekend_overtime_rate + if pay_config and pay_config.weekend_overtime_rate is not None + else self.config.payroll.default_weekend_overtime_rate + ) + holiday_overtime_rate = ( + pay_config.holiday_overtime_rate + if pay_config and pay_config.holiday_overtime_rate is not None + else self.config.payroll.default_holiday_overtime_rate + ) + overtime_pay = round( + workday_overtime_hours * overtime_rate + + weekend_overtime_hours * weekend_overtime_rate + + holiday_overtime_hours * holiday_overtime_rate, + 2, + ) + commission_amount = round(pay_config.commission_amount, 2) if pay_config else 0.0 + + gross_salary = None + net_salary = None + note = "" + if base_pay is None: + note = "未配置薪资档案,已计算考勤扣款和剩余加班工时" + else: + gross_salary = round(base_pay + commission_amount + overtime_pay, 2) + net_salary = round(gross_salary - total_deduction, 2) + + return PayrollResult( + employee=employee, + salary_month=salary_month, + salary_mode=salary_mode, + attendance_days=attendance_days, + absence_days=absence_days, + actual_work_hours=actual_work_hours, + punch_overtime_hours=punch_overtime_hours, + leave_hours=leave_hours, + remaining_overtime_hours=remaining_overtime_hours, + workday_overtime_hours=workday_overtime_hours, + weekend_overtime_hours=weekend_overtime_hours, + holiday_overtime_hours=holiday_overtime_hours, + minor_late_count=minor_late_count, + major_late_count=major_late_count, + penalized_late_count=penalized_late_count, + late_deduction=late_deduction, + missing_card_count=missing_card_count, + missing_card_deduction=missing_card_deduction, + total_deduction=total_deduction, + base_salary=base_salary, + base_pay=base_pay, + commission_amount=commission_amount, + overtime_rate=overtime_rate, + weekend_overtime_rate=weekend_overtime_rate, + holiday_overtime_rate=holiday_overtime_rate, + overtime_pay=overtime_pay, + gross_salary=gross_salary, + net_salary=net_salary, + note=note, + ) + + def _split_overtime_hours(self, employee: EmployeeAttendance) -> tuple[float, float, float]: + workday_hours = 0.0 + weekend_hours = 0.0 + holiday_hours = 0.0 + holidays = set(self.config.attendance.legal_holidays) + weekend_days = set(self.config.attendance.weekend_days) + for record in employee.daily_records: + if record.work_date.isoformat() in holidays: + holiday_hours += record.punch_overtime_hours + elif record.work_date.weekday() in weekend_days: + weekend_hours += record.punch_overtime_hours + else: + workday_hours += record.punch_overtime_hours + return round(workday_hours, 2), round(weekend_hours, 2), round(holiday_hours, 2) + + def _overtime_from_checkout(self, record: DailyAttendance) -> float: + """按下班打卡时间向下取整计算加班,例如 20:30 -> 3 小时。""" + checkout = record.last_punch + if checkout is None or not _is_after(checkout, self.off_work_time): + return 0.0 + + checkout_minutes = checkout.hour * 60 + checkout.minute + off_work_minutes = self.off_work_time.hour * 60 + self.off_work_time.minute + overtime_minutes = checkout_minutes - off_work_minutes + round_minutes = self.config.attendance.overtime_round_minutes + return (overtime_minutes // round_minutes) * round_minutes / 60 + + +def _is_after(left: time, right: time) -> bool: + return (left.hour, left.minute) > (right.hour, right.minute) + + +def _number(value: object) -> float: + if value in (None, ""): + return 0.0 + try: + return float(value) + except (TypeError, ValueError): + return 0.0 + + +def _salary_month(employee: EmployeeAttendance) -> str: + if not employee.daily_records: + return "" + return employee.daily_records[0].work_date.strftime("%Y-%m") + + +def _count_attendance_days(employee: EmployeeAttendance) -> float: + return float( + sum(1 for record in employee.daily_records if record.first_punch is not None or record.last_punch is not None) + ) + + +def _expected_work_days(employee: EmployeeAttendance, weekend_days: tuple[int, ...]) -> float: + configured = _number(employee.summary_fields.get("应出勤天数")) + if configured: + return configured + weekends = set(weekend_days) + return float(sum(1 for record in employee.daily_records if record.work_date.weekday() not in weekends)) + + +def _allocate_payable_overtime( + workday_hours: float, + weekend_hours: float, + holiday_hours: float, + leave_hours: float, +) -> tuple[float, float, float]: + """请假工时先冲抵工作日加班,再冲抵周末和节假日加班。""" + remaining_leave = max(0.0, leave_hours) + workday_payable = max(0.0, workday_hours - remaining_leave) + remaining_leave = max(0.0, remaining_leave - workday_hours) + weekend_payable = max(0.0, weekend_hours - remaining_leave) + remaining_leave = max(0.0, remaining_leave - weekend_hours) + holiday_payable = max(0.0, holiday_hours - remaining_leave) + return round(workday_payable, 2), round(weekend_payable, 2), round(holiday_payable, 2) + + +def _base_pay(pay_config, actual_work_hours: float) -> float | None: + if pay_config is None: + return None + mode = pay_config.salary_mode + if mode == "hourly": + return round(pay_config.hourly_rate * actual_work_hours, 2) + if mode == "piecework": + return round(pay_config.piece_quantity * pay_config.piece_unit_price, 2) + if mode == "probation": + if pay_config.probation_type == "fixed" and pay_config.probation_salary is not None: + return round(pay_config.probation_salary, 2) + if pay_config.base_salary is None: + return None + return round(pay_config.base_salary * pay_config.probation_ratio, 2) + if pay_config.base_salary is None: + return None + return round(pay_config.base_salary, 2) diff --git a/financial_system/domain/models.py b/financial_system/domain/models.py new file mode 100644 index 0000000..43bbf0c --- /dev/null +++ b/financial_system/domain/models.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import date, time +from typing import Optional + + +@dataclass(frozen=True) +class LeaveEvent: + """请假/调休等工时事件,key 用于跨天重复记录去重。""" + + kind: str + start_text: str + end_text: str + hours: float + + @property + def key(self) -> tuple[str, str, str, float]: + return (self.kind, self.start_text, self.end_text, round(self.hours, 4)) + + +@dataclass +class DailyAttendance: + """单个员工某一天的考勤明细。""" + + work_date: date + raw_text: str = "" + first_punch: Optional[time] = None + last_punch: Optional[time] = None + late_minutes: list[int] = field(default_factory=list) + missing_card_count: int = 0 + leave_events: list[LeaveEvent] = field(default_factory=list) + punch_overtime_hours: float = 0.0 + + @property + def leave_hours_in_cell(self) -> float: + return sum(event.hours for event in self.leave_events) + + +@dataclass +class EmployeeAttendance: + """员工月度考勤数据,包含汇总字段和每日明细。""" + + name: str + attendance_group: str = "" + department: str = "" + employee_no: str = "" + position: str = "" + user_id: str = "" + summary_fields: dict[str, float | str] = field(default_factory=dict) + daily_records: list[DailyAttendance] = field(default_factory=list) + + @property + def unique_leave_events(self) -> list[LeaveEvent]: + seen: set[tuple[str, str, str, float]] = set() + result: list[LeaveEvent] = [] + for record in self.daily_records: + for event in record.leave_events: + if event.key not in seen: + seen.add(event.key) + result.append(event) + return result + + +@dataclass(frozen=True) +class EmployeePayConfig: + """员工工资配置;计算器只关心规则值,不关心这些值来自数据库还是配置文件。""" + + salary_mode: str = "monthly" + base_salary: Optional[float] = None + hourly_rate: float = 0.0 + piece_quantity: float = 0.0 + piece_unit_price: float = 0.0 + probation_type: str = "ratio" + probation_ratio: float = 1.0 + probation_salary: Optional[float] = None + commission_amount: float = 0.0 + overtime_rate: Optional[float] = None + weekend_overtime_rate: Optional[float] = None + holiday_overtime_rate: Optional[float] = None + + +@dataclass(frozen=True) +class PayrollResult: + """员工级工资计算结果。""" + + employee: EmployeeAttendance + salary_month: str + salary_mode: str + attendance_days: float + absence_days: float + actual_work_hours: float + punch_overtime_hours: float + leave_hours: float + remaining_overtime_hours: float + workday_overtime_hours: float + weekend_overtime_hours: float + holiday_overtime_hours: float + minor_late_count: int + major_late_count: int + penalized_late_count: int + late_deduction: float + missing_card_count: int + missing_card_deduction: float + total_deduction: float + base_salary: Optional[float] + base_pay: Optional[float] + commission_amount: float + overtime_rate: float + weekend_overtime_rate: float + holiday_overtime_rate: float + overtime_pay: float + gross_salary: Optional[float] + net_salary: Optional[float] + note: str = "" diff --git a/financial_system/integrations/__init__.py b/financial_system/integrations/__init__.py new file mode 100644 index 0000000..bc8e8fc --- /dev/null +++ b/financial_system/integrations/__init__.py @@ -0,0 +1 @@ +"""External system integrations.""" diff --git a/financial_system/integrations/dingtalk.py b/financial_system/integrations/dingtalk.py new file mode 100644 index 0000000..ab87c02 --- /dev/null +++ b/financial_system/integrations/dingtalk.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass +from datetime import date, datetime, time +from typing import Any, Iterable +from zoneinfo import ZoneInfo + +import httpx + +from ..core.config import AttendanceRules +from ..core.logger import AppLogger +from ..domain.models import DailyAttendance, EmployeeAttendance + +logger = AppLogger.get_logger(__name__) + + +class DingTalkError(RuntimeError): + pass + + +@dataclass(frozen=True) +class DingTalkSettings: + app_key: str + app_secret: str + base_url: str = "https://oapi.dingtalk.com" + timeout_seconds: float = 15.0 + timezone: str = "Asia/Shanghai" + + +class DingTalkClient: + """钉钉 OpenAPI 客户端,只负责 HTTP 请求和接口错误转换。""" + + def __init__(self, settings: DingTalkSettings): + self.settings = settings + + async def get_access_token(self) -> str: + logger.info("钉钉 access_token 请求开始 base_url=%s", self.settings.base_url) + async with httpx.AsyncClient( + base_url=self.settings.base_url, + timeout=self.settings.timeout_seconds, + ) as client: + response = await client.get( + "/gettoken", + params={"appkey": self.settings.app_key, "appsecret": self.settings.app_secret}, + ) + payload = response.json() + self._raise_for_dingtalk_error(payload) + token = payload.get("access_token") + if not token: + logger.error("钉钉 access_token 响应缺失 payload=%s", payload) + raise DingTalkError(f"钉钉未返回 access_token:{payload}") + logger.info("钉钉 access_token 请求成功") + return token + + async def list_attendance_records( + self, + user_ids: list[str], + start_time: datetime, + end_time: datetime, + ) -> list[dict[str, Any]]: + logger.info( + "钉钉考勤记录请求开始 user_count=%s start_time=%s end_time=%s", + len(user_ids), + start_time, + end_time, + ) + token = await self.get_access_token() + body = { + "userIds": user_ids, + "checkDateFrom": start_time.strftime("%Y-%m-%d %H:%M:%S"), + "checkDateTo": end_time.strftime("%Y-%m-%d %H:%M:%S"), + } + async with httpx.AsyncClient( + base_url=self.settings.base_url, + timeout=self.settings.timeout_seconds, + ) as client: + response = await client.post( + "/topapi/attendance/listRecord", + params={"access_token": token}, + json=body, + ) + payload = response.json() + self._raise_for_dingtalk_error(payload) + records = payload.get("recordresult") or payload.get("result", {}).get("recordresult") or [] + logger.info("钉钉考勤记录请求成功 record_count=%s", len(records)) + return records + + def _raise_for_dingtalk_error(self, payload: dict[str, Any]) -> None: + errcode = payload.get("errcode") + if errcode not in (None, 0): + errmsg = payload.get("errmsg", "未知错误") + logger.error("钉钉接口返回错误 errcode=%s errmsg=%s", errcode, errmsg) + raise DingTalkError(f"钉钉接口错误 {errcode}: {errmsg}") + + +class DingTalkAttendanceAdapter: + """把钉钉实时打卡记录适配成工资计算器可消费的考勤模型。""" + + def __init__(self, rules: AttendanceRules, timezone: str = "Asia/Shanghai"): + self.timezone = ZoneInfo(timezone) + self.on_work_time = datetime.strptime(rules.on_work_time, "%H:%M").time() + + def to_employee_attendance(self, records: Iterable[dict[str, Any]]) -> list[EmployeeAttendance]: + records = list(records) + logger.info("钉钉考勤记录适配开始 record_count=%s", len(records)) + grouped: dict[str, dict[date, list[dict[str, Any]]]] = defaultdict(lambda: defaultdict(list)) + names: dict[str, str] = {} + departments: dict[str, str] = {} + + for record in records: + user_id = str(record.get("userId") or record.get("userid") or "").strip() + if not user_id: + continue + check_dt = self._record_datetime(record) + if check_dt is None: + continue + grouped[user_id][check_dt.date()].append(record) + names[user_id] = str(record.get("userName") or record.get("name") or user_id) + departments[user_id] = str(record.get("departmentName") or record.get("department") or "") + + employees: list[EmployeeAttendance] = [] + for user_id, by_day in grouped.items(): + employee = EmployeeAttendance( + name=names.get(user_id, user_id), + user_id=user_id, + department=departments.get(user_id, ""), + attendance_group="钉钉实时考勤", + ) + for work_date in sorted(by_day): + employee.daily_records.append(self._build_daily_record(work_date, by_day[work_date])) + employees.append(employee) + logger.info("钉钉考勤记录适配完成 employee_count=%s", len(employees)) + return employees + + def _build_daily_record(self, work_date: date, records: list[dict[str, Any]]) -> DailyAttendance: + dated_records = [ + (check_dt, record) + for record in records + if (check_dt := self._record_datetime(record)) is not None + ] + dated_records.sort(key=lambda item: item[0]) + punch_times = [check_dt.time() for check_dt, _ in dated_records] + raw_text = ";".join( + self._record_summary(record, check_dt) for check_dt, record in dated_records + ) + first_punch = punch_times[0] if punch_times else None + late_minutes = self._late_minutes(first_punch) + return DailyAttendance( + work_date=work_date, + raw_text=raw_text, + first_punch=first_punch, + last_punch=punch_times[-1] if punch_times else None, + late_minutes=[late_minutes] if late_minutes > 0 else [], + missing_card_count=sum(1 for item in records if self._is_missing_card(item)), + ) + + def _record_datetime(self, record: dict[str, Any]) -> datetime | None: + value = record.get("userCheckTime") or record.get("checkTime") or record.get("baseCheckTime") + if value in (None, ""): + return None + if isinstance(value, (int, float)): + return datetime.fromtimestamp(value / 1000, tz=self.timezone).replace(tzinfo=None) + text = str(value).strip() + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M"): + try: + return datetime.strptime(text[:19], fmt) + except ValueError: + continue + return None + + def _record_summary(self, record: dict[str, Any], check_dt: datetime | None) -> str: + check_time = check_dt.strftime("%H:%M") if check_dt else "-" + check_type = record.get("checkType") or record.get("type") or "" + time_result = record.get("timeResult") or "" + location_result = record.get("locationResult") or "" + return f"{check_type} {check_time} {time_result} {location_result}".strip() + + def _late_minutes(self, first_punch: time | None) -> int: + if first_punch is None: + return 0 + punch_minutes = first_punch.hour * 60 + first_punch.minute + on_work_minutes = self.on_work_time.hour * 60 + self.on_work_time.minute + return max(0, punch_minutes - on_work_minutes) + + def _is_missing_card(self, record: dict[str, Any]) -> bool: + values = { + str(record.get("timeResult") or ""), + str(record.get("checkType") or ""), + str(record.get("sourceType") or ""), + } + joined = " ".join(values).lower() + return "notsigned" in joined or "缺卡" in joined diff --git a/financial_system/io/__init__.py b/financial_system/io/__init__.py new file mode 100644 index 0000000..2b6dbda --- /dev/null +++ b/financial_system/io/__init__.py @@ -0,0 +1 @@ +"""Input/output adapters for Excel and local files.""" diff --git a/financial_system/io/exporter.py b/financial_system/io/exporter.py new file mode 100644 index 0000000..82a0bee --- /dev/null +++ b/financial_system/io/exporter.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +from pathlib import Path + +from openpyxl import Workbook +from openpyxl.styles import Alignment, Font, PatternFill +from openpyxl.utils import get_column_letter + +from ..core.config import AppConfig +from ..domain.models import PayrollResult +from .parser import iter_leave_event_descriptions + + +SUMMARY_HEADERS = [ + "姓名", + "部门", + "职位", + "考勤组", + "工资月份", + "薪资模式", + "出勤天数", + "缺勤天数", + "实际工作工时", + "休息天数", + "系统加班总时长", + "打卡推算加班工时", + "请假/调休工时", + "剩余加班工时", + "工作日加班工时", + "周末加班工时", + "节假日加班工时", + "5分钟内迟到次数", + "超过5分钟迟到次数", + "计扣迟到次数", + "迟到扣款", + "缺卡次数", + "缺卡扣款", + "扣款合计", + "底薪", + "模式基础工资", + "提成", + "工作日加班单价", + "周末加班单价", + "节假日加班单价", + "加班费", + "应发工资", + "实发工资", + "备注", +] + +DAILY_HEADERS = [ + "姓名", + "日期", + "原始考勤结果", + "上班打卡", + "下班打卡", + "打卡推算加班工时", + "迟到分钟", + "缺卡次数", + "请假/调休工时(本格)", + "请假/调休记录", +] + + +def export_payroll_results(results: list[PayrollResult], config: AppConfig, output_path: str | Path) -> Path: + """导出工资汇总、每日明细和规则说明三张表。""" + output_path = Path(output_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + + workbook = Workbook() + summary_sheet = workbook.active + summary_sheet.title = "工资汇总" + daily_sheet = workbook.create_sheet("每日明细") + rules_sheet = workbook.create_sheet("规则说明") + + _write_summary(summary_sheet, results) + _write_daily_details(daily_sheet, results) + _write_rules(rules_sheet, config) + + for sheet in workbook.worksheets: + _style_sheet(sheet) + + workbook.save(output_path) + return output_path + + +def _write_summary(sheet, results: list[PayrollResult]) -> None: + sheet.append(SUMMARY_HEADERS) + for result in results: + employee = result.employee + row = [ + employee.name, + employee.department, + employee.position, + employee.attendance_group, + result.salary_month, + result.salary_mode, + result.attendance_days, + result.absence_days, + result.actual_work_hours, + employee.summary_fields.get("休息天数", 0), + employee.summary_fields.get("加班总时长", 0), + result.punch_overtime_hours, + result.leave_hours, + result.remaining_overtime_hours, + result.workday_overtime_hours, + result.weekend_overtime_hours, + result.holiday_overtime_hours, + result.minor_late_count, + result.major_late_count, + result.penalized_late_count, + result.late_deduction, + result.missing_card_count, + result.missing_card_deduction, + result.total_deduction, + result.base_salary, + result.base_pay, + result.commission_amount, + result.overtime_rate, + result.weekend_overtime_rate, + result.holiday_overtime_rate, + result.overtime_pay, + result.gross_salary, + result.net_salary, + result.note, + ] + sheet.append(row) + + +def _write_daily_details(sheet, results: list[PayrollResult]) -> None: + sheet.append(DAILY_HEADERS) + for result in results: + for record in result.employee.daily_records: + sheet.append( + [ + result.employee.name, + record.work_date, + record.raw_text, + _format_time(record.first_punch), + _format_time(record.last_punch), + record.punch_overtime_hours, + ",".join(str(minutes) for minutes in record.late_minutes), + record.missing_card_count, + record.leave_hours_in_cell, + ";".join(iter_leave_event_descriptions(record.leave_events)), + ] + ) + + +def _write_rules(sheet, config: AppConfig) -> None: + rows = [ + ("下班时间", config.attendance.off_work_time), + ("加班取整分钟", config.attendance.overtime_round_minutes), + ("加班计算", "以下班打卡时间为准,超过下班时间后按取整分钟向下取整"), + ("5分钟内迟到免扣次数", config.attendance.minor_late_free_times), + ("迟到分钟阈值", config.attendance.minor_late_minutes), + ("迟到扣款/次", config.attendance.late_penalty), + ("缺卡扣款/次", config.attendance.missing_card_penalty), + ("请假/调休关键字", "、".join(config.attendance.leave_keywords)), + ("剩余加班工时", "打卡推算加班工时 - 去重后的请假/调休工时"), + ] + sheet.append(["规则", "值"]) + for row in rows: + sheet.append(row) + + +def _style_sheet(sheet) -> None: + header_fill = PatternFill("solid", fgColor="1F4E78") + header_font = Font(color="FFFFFF", bold=True) + for cell in sheet[1]: + cell.fill = header_fill + cell.font = header_font + cell.alignment = Alignment(horizontal="center", vertical="center") + + sheet.freeze_panes = "A2" + for row in sheet.iter_rows(min_row=2): + for cell in row: + cell.alignment = Alignment(vertical="top", wrap_text=True) + + for col_idx, cells in enumerate(sheet.columns, start=1): + max_length = 0 + for cell in cells: + value = cell.value + if value is None: + continue + text = str(value) + max_length = max(max_length, min(len(text), 45)) + sheet.column_dimensions[get_column_letter(col_idx)].width = max(10, max_length + 2) + + +def _format_time(value) -> str: + if value is None: + return "" + return value.strftime("%H:%M") diff --git a/financial_system/io/parser.py b/financial_system/io/parser.py new file mode 100644 index 0000000..c3314a6 --- /dev/null +++ b/financial_system/io/parser.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +import re +from datetime import date, datetime, time, timedelta +from pathlib import Path +from typing import Iterable + +from openpyxl import load_workbook + +from ..core.config import AttendanceRules +from ..domain.models import DailyAttendance, EmployeeAttendance, LeaveEvent + +PUNCH_LINE_RE = re.compile(r"\((?P[^()]*)\)\s*$", re.S) +TIME_RE = re.compile(r"^\d{1,2}:\d{2}$") +LATE_RE = re.compile(r"迟到(?P\d+(?:\.\d+)?)分钟") +PERIOD_RE = re.compile(r"统计日期:(?P\d{4}-\d{2}-\d{2})\s+至\s+(?P\d{4}-\d{2}-\d{2})") + +BASE_COLUMNS = ("姓名", "考勤组", "部门", "工号", "职位", "UserId") + + +class MonthlySummaryParser: + """解析钉钉月度汇总 Excel,输出统一的员工考勤领域模型。""" + + def __init__(self, rules: AttendanceRules): + self.rules = rules + self.leave_re = re.compile( + rf"(?P{'|'.join(map(re.escape, rules.leave_keywords))})" + r"(?P\d{2}-\d{2}\s+\d{2}:\d{2})到" + r"(?P\d{2}-\d{2}\s+\d{2}:\d{2})\s+" + r"(?P\d+(?:\.\d+)?)小时" + ) + + def parse(self, workbook_path: str | Path) -> list[EmployeeAttendance]: + workbook = load_workbook(workbook_path, data_only=True) + sheet = workbook.active + start_date, _ = self._period(sheet["A1"].value) + header_row = 3 + data_start_row = 5 + + daily_start_col = self._find_daily_start_col(sheet, header_row) + header_map = self._header_map(sheet, header_row, daily_start_col) + employees: list[EmployeeAttendance] = [] + + for row_idx in range(data_start_row, sheet.max_row + 1): + name = self._cell_text(sheet.cell(row_idx, header_map["姓名"]).value) + if not name: + continue + + employee = EmployeeAttendance( + name=name, + attendance_group=self._value_by_header(sheet, row_idx, header_map, "考勤组"), + department=self._value_by_header(sheet, row_idx, header_map, "部门"), + employee_no=self._value_by_header(sheet, row_idx, header_map, "工号"), + position=self._value_by_header(sheet, row_idx, header_map, "职位"), + user_id=self._value_by_header(sheet, row_idx, header_map, "UserId"), + summary_fields=self._summary_fields(sheet, row_idx, header_map), + ) + + for col_idx in range(daily_start_col, sheet.max_column + 1): + work_date = start_date + timedelta(days=col_idx - daily_start_col) + raw_text = self._cell_text(sheet.cell(row_idx, col_idx).value) + record = self._parse_daily_record(work_date, raw_text) + employee.daily_records.append(record) + + employees.append(employee) + + return employees + + def _parse_daily_record(self, work_date: date, raw_text: str) -> DailyAttendance: + punch_times = self._parse_punch_times(raw_text) + record = DailyAttendance( + work_date=work_date, + raw_text=raw_text, + first_punch=punch_times[0] if punch_times else None, + last_punch=punch_times[-1] if punch_times else None, + late_minutes=self._parse_late_minutes(raw_text), + missing_card_count=self._parse_missing_cards(raw_text), + leave_events=self._parse_leave_events(raw_text), + ) + return record + + def _parse_punch_times(self, raw_text: str) -> list[time]: + """每日单元格最后一行形如 (08:30,17:00),这里只取真实打卡时间。""" + match = PUNCH_LINE_RE.search(raw_text or "") + if not match: + return [] + result: list[time] = [] + for item in match.group("punches").split(","): + text = item.strip() + if not TIME_RE.match(text): + continue + result.append(datetime.strptime(text, "%H:%M").time()) + return result + + def _parse_late_minutes(self, raw_text: str) -> list[int]: + return [int(float(match.group("minutes"))) for match in LATE_RE.finditer(raw_text or "")] + + def _parse_missing_cards(self, raw_text: str) -> int: + return (raw_text or "").count("缺卡") + + def _parse_leave_events(self, raw_text: str) -> list[LeaveEvent]: + """提取请假/调休事件;跨天申请后续会按事件 key 去重。""" + return [ + LeaveEvent( + kind=match.group("kind"), + start_text=match.group("start"), + end_text=match.group("end"), + hours=float(match.group("hours")), + ) + for match in self.leave_re.finditer(raw_text or "") + ] + + def _period(self, title: object) -> tuple[date, date]: + match = PERIOD_RE.search(self._cell_text(title)) + if not match: + raise ValueError("未能从 A1 识别统计日期,期望格式:统计日期:YYYY-MM-DD 至 YYYY-MM-DD") + return ( + datetime.strptime(match.group("start"), "%Y-%m-%d").date(), + datetime.strptime(match.group("end"), "%Y-%m-%d").date(), + ) + + def _find_daily_start_col(self, sheet, header_row: int) -> int: + for col_idx in range(1, sheet.max_column + 1): + if self._cell_text(sheet.cell(header_row, col_idx).value) == "考勤结果": + return col_idx + raise ValueError("未找到“考勤结果”列,无法识别每日考勤明细。") + + def _header_map(self, sheet, header_row: int, stop_col: int) -> dict[str, int]: + mapping: dict[str, int] = {} + for col_idx in range(1, stop_col): + header = self._cell_text(sheet.cell(header_row, col_idx).value) + if header: + mapping[header] = col_idx + missing = [header for header in BASE_COLUMNS if header not in mapping] + if missing: + raise ValueError(f"缺少必要表头:{', '.join(missing)}") + return mapping + + def _summary_fields(self, sheet, row_idx: int, header_map: dict[str, int]) -> dict[str, float | str]: + fields: dict[str, float | str] = {} + for header, col_idx in header_map.items(): + if header in BASE_COLUMNS: + continue + value = sheet.cell(row_idx, col_idx).value + fields[header] = self._normalize_value(value) + return fields + + def _value_by_header(self, sheet, row_idx: int, header_map: dict[str, int], header: str) -> str: + return self._cell_text(sheet.cell(row_idx, header_map[header]).value) + + def _normalize_value(self, value: object) -> float | str: + text = self._cell_text(value) + if text == "": + return 0.0 + try: + return float(text) + except ValueError: + return text + + def _cell_text(self, value: object) -> str: + if value is None: + return "" + return str(value).strip() + + +def iter_leave_event_descriptions(events: Iterable[LeaveEvent]) -> Iterable[str]: + for event in events: + yield f"{event.kind}{event.start_text}到{event.end_text} {event.hours:g}小时" diff --git a/financial_system/io/storage.py b/financial_system/io/storage.py new file mode 100644 index 0000000..92bc34f --- /dev/null +++ b/financial_system/io/storage.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import shutil +from pathlib import Path +from typing import BinaryIO +from uuid import uuid4 + +from ..core.logger import AppLogger +from ..core.settings import AppSettings + +logger = AppLogger.get_logger(__name__) + +ALLOWED_AVATAR_SUFFIXES = {".jpg", ".jpeg", ".png", ".webp", ".gif"} + + +class FileStorage: + """统一管理上传文件和导出文件路径,避免路由层直接操作磁盘。""" + + def __init__(self, settings: AppSettings): + self.settings = settings + + def save_upload(self, filename: str, stream: BinaryIO) -> Path: + if not filename.lower().endswith(".xlsx"): + logger.error("上传文件格式错误 filename=%s", filename) + raise ValueError("请上传 .xlsx 格式的月度汇总 Excel") + self.settings.upload_dir.mkdir(parents=True, exist_ok=True) + path = self.settings.upload_dir / f"attendance_{uuid4().hex}{Path(filename).suffix}" + with path.open("wb") as buffer: + shutil.copyfileobj(stream, buffer) + logger.info("上传文件保存完成 filename=%s path=%s", filename, path) + return path + + def payroll_output_path(self, source_type: str) -> Path: + self.settings.output_dir.mkdir(parents=True, exist_ok=True) + path = self.settings.output_dir / f"payroll_{source_type}_{uuid4().hex}.xlsx" + logger.info("工资结果输出路径已生成 source_type=%s path=%s", source_type, path) + return path + + def resolve_output_file(self, filename: str) -> Path: + """只允许下载输出目录内文件,避免路径穿越。""" + path = (self.settings.output_dir / Path(filename).name).resolve() + output_dir = self.settings.output_dir.resolve() + if output_dir not in path.parents or not path.is_file(): + logger.error("下载文件不存在或越权 filename=%s resolved_path=%s", filename, path) + raise FileNotFoundError("文件不存在") + logger.info("下载文件解析完成 filename=%s path=%s", filename, path) + return path + + def save_avatar(self, filename: str, stream: BinaryIO) -> str: + """保存用户头像并返回可供前端访问的静态资源路径。""" + suffix = Path(filename).suffix.lower() + if suffix not in ALLOWED_AVATAR_SUFFIXES: + logger.error("头像文件格式错误 filename=%s", filename) + raise ValueError("请上传 jpg、jpeg、png、webp 或 gif 格式头像") + + self.settings.avatar_dir.mkdir(parents=True, exist_ok=True) + path = self.settings.avatar_dir / f"avatar_{uuid4().hex}{suffix}" + with path.open("wb") as buffer: + shutil.copyfileobj(stream, buffer) + logger.info("头像文件保存完成 filename=%s path=%s", filename, path) + return f"/static/uploads/avatars/{path.name}" diff --git a/financial_system/repositories/__init__.py b/financial_system/repositories/__init__.py new file mode 100644 index 0000000..a21bf23 --- /dev/null +++ b/financial_system/repositories/__init__.py @@ -0,0 +1,21 @@ +from .commission_repository import CommissionRepository +from .employee_repository import EmployeeRepository +from .operation_log_repository import OperationLogRepository +from .organization_repository import OrganizationRepository +from .payroll_repository import PayrollRepository +from .report_repository import ReportRepository +from .salary_profile_repository import SalaryProfileRepository +from .salary_config_repository import SalaryConfigRepository +from .user_repository import UserRepository + +__all__ = [ + "CommissionRepository", + "EmployeeRepository", + "OperationLogRepository", + "OrganizationRepository", + "PayrollRepository", + "ReportRepository", + "SalaryConfigRepository", + "SalaryProfileRepository", + "UserRepository", +] diff --git a/financial_system/repositories/commission_repository.py b/financial_system/repositories/commission_repository.py new file mode 100644 index 0000000..5b9c5bf --- /dev/null +++ b/financial_system/repositories/commission_repository.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +from sqlalchemy import func, or_ +from sqlalchemy.orm import Session, joinedload + +from ..database.orm import CommissionRecordORM, EmployeeORM + + +class CommissionRepository: + """提成仓库:只负责 commission_record 表的读写和月度汇总。""" + + def __init__(self, session: Session): + self.session = session + + def create_record( + self, + *, + employee_id: int, + commission_month: str, + commission_type: str, + amount: float, + source_type: str, + business_ref: str, + remark: str, + ) -> CommissionRecordORM: + record = CommissionRecordORM( + employee_id=employee_id, + commission_month=commission_month, + commission_type=commission_type, + amount=amount, + source_type=source_type, + business_ref=business_ref, + remark=remark, + ) + self.session.add(record) + self.session.commit() + return self.require_by_id(record.id) + + def update_record( + self, + record_id: int, + *, + employee_id: int, + commission_month: str, + commission_type: str, + amount: float, + source_type: str, + business_ref: str, + remark: str, + ) -> CommissionRecordORM: + record = self.require_by_id(record_id) + record.employee_id = employee_id + record.commission_month = commission_month + record.commission_type = commission_type + record.amount = amount + record.source_type = source_type + record.business_ref = business_ref + record.remark = remark + self.session.commit() + return self.require_by_id(record_id) + + def delete_record(self, record_id: int) -> None: + record = self.require_by_id(record_id) + self.session.delete(record) + self.session.commit() + + def require_by_id(self, record_id: int) -> CommissionRecordORM: + record = ( + self.session.query(CommissionRecordORM) + .options(joinedload(CommissionRecordORM.employee)) + .filter(CommissionRecordORM.id == record_id) + .one_or_none() + ) + if record is None: + raise ValueError("提成记录不存在") + return record + + def list_records( + self, + *, + page: int, + page_size: int, + commission_month: str | None = None, + keyword: str | None = None, + ) -> tuple[list[CommissionRecordORM], int]: + query = self.session.query(CommissionRecordORM).options(joinedload(CommissionRecordORM.employee)) + if commission_month: + query = query.filter(CommissionRecordORM.commission_month == commission_month) + if keyword: + like_keyword = f"%{keyword}%" + query = query.join(CommissionRecordORM.employee).filter( + or_( + EmployeeORM.employee_no.like(like_keyword), + EmployeeORM.name.like(like_keyword), + EmployeeORM.department.like(like_keyword), + CommissionRecordORM.commission_type.like(like_keyword), + CommissionRecordORM.business_ref.like(like_keyword), + ) + ) + total = query.count() + items = ( + query.order_by(CommissionRecordORM.commission_month.desc(), CommissionRecordORM.id.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + return items, total + + def aggregate_by_month(self, commission_month: str) -> dict[int, float]: + rows = ( + self.session.query(CommissionRecordORM.employee_id, func.sum(CommissionRecordORM.amount)) + .filter(CommissionRecordORM.commission_month == commission_month) + .group_by(CommissionRecordORM.employee_id) + .all() + ) + return {employee_id: float(amount or 0) for employee_id, amount in rows} + + def create_many(self, records: list[CommissionRecordORM]) -> int: + self.session.add_all(records) + self.session.commit() + return len(records) diff --git a/financial_system/repositories/employee_repository.py b/financial_system/repositories/employee_repository.py new file mode 100644 index 0000000..e9d6093 --- /dev/null +++ b/financial_system/repositories/employee_repository.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from ..database.orm import EmployeeORM + + +class EmployeeRepository: + """员工仓库:封装 employees 表的基础维护。""" + + def __init__(self, session: Session): + self.session = session + + def create_employee( + self, + *, + employee_no: str, + name: str, + dingtalk_user_id: str = "", + department: str = "", + position: str = "", + phone: str = "", + employment_status: str = "active", + remark: str = "", + ) -> EmployeeORM: + employee = EmployeeORM( + employee_no=employee_no, + name=name, + dingtalk_user_id=dingtalk_user_id, + department=department, + position=position, + phone=phone, + employment_status=employment_status, + remark=remark, + ) + self.session.add(employee) + self.session.commit() + self.session.refresh(employee) + return employee + + def update_employee( + self, + employee_id: int, + *, + employee_no: str, + name: str, + dingtalk_user_id: str, + department: str, + position: str, + phone: str, + employment_status: str, + remark: str, + ) -> EmployeeORM: + employee = self.require_by_id(employee_id) + employee.employee_no = employee_no + employee.name = name + employee.dingtalk_user_id = dingtalk_user_id + employee.department = department + employee.position = position + employee.phone = phone + employee.employment_status = employment_status + employee.remark = remark + self.session.commit() + self.session.refresh(employee) + return employee + + def get_by_id(self, employee_id: int) -> EmployeeORM | None: + return self.session.query(EmployeeORM).filter(EmployeeORM.id == employee_id).one_or_none() + + def require_by_id(self, employee_id: int) -> EmployeeORM: + employee = self.get_by_id(employee_id) + if employee is None: + raise ValueError("员工不存在") + return employee + + def get_by_employee_no(self, employee_no: str) -> EmployeeORM | None: + return self.session.query(EmployeeORM).filter(EmployeeORM.employee_no == employee_no).one_or_none() + + def list_employee_numbers_by_prefix(self, prefix: str) -> list[str]: + """查询指定前缀的员工编号,供服务层生成下一个编号。""" + rows = ( + self.session.query(EmployeeORM.employee_no) + .filter(EmployeeORM.employee_no.like(f"{prefix}%")) + .all() + ) + return [row[0] for row in rows] + + def list_employees( + self, + *, + keyword: str | None = None, + employment_status: str | None = None, + ) -> list[EmployeeORM]: + query = self._filtered_query(keyword=keyword, employment_status=employment_status) + return query.order_by(EmployeeORM.id.desc()).all() + + def list_employees_page( + self, + *, + page: int, + page_size: int, + keyword: str | None = None, + employment_status: str | None = None, + ) -> tuple[list[EmployeeORM], int]: + query = self._filtered_query(keyword=keyword, employment_status=employment_status) + total = query.count() + items = ( + query.order_by(EmployeeORM.id.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + return items, total + + def _filtered_query(self, *, keyword: str | None, employment_status: str | None): + query = self.session.query(EmployeeORM) + if employment_status: + query = query.filter(EmployeeORM.employment_status == employment_status) + if keyword: + like_keyword = f"%{keyword}%" + query = query.filter( + or_( + EmployeeORM.employee_no.like(like_keyword), + EmployeeORM.name.like(like_keyword), + EmployeeORM.dingtalk_user_id.like(like_keyword), + EmployeeORM.department.like(like_keyword), + EmployeeORM.position.like(like_keyword), + EmployeeORM.phone.like(like_keyword), + ) + ) + return query diff --git a/financial_system/repositories/operation_log_repository.py b/financial_system/repositories/operation_log_repository.py new file mode 100644 index 0000000..96d07bc --- /dev/null +++ b/financial_system/repositories/operation_log_repository.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from ..database.orm import OperationLogORM + + +class OperationLogRepository: + """操作日志仓库:只负责 operation_logs 表的写入和查询。""" + + def __init__(self, session: Session): + self.session = session + + def create_log( + self, + *, + user_id: int | None, + username: str, + role: str, + module: str, + action: str, + target_type: str, + target_id: str, + status: str, + detail: str, + ip_address: str, + user_agent: str, + ) -> OperationLogORM: + log = OperationLogORM( + user_id=user_id, + username=username, + role=role, + module=module, + action=action, + target_type=target_type, + target_id=target_id, + status=status, + detail=detail, + ip_address=ip_address, + user_agent=user_agent, + ) + self.session.add(log) + self.session.commit() + self.session.refresh(log) + return log + + def list_logs( + self, + *, + page: int, + page_size: int, + username: str | None = None, + module: str | None = None, + action: str | None = None, + status: str | None = None, + keyword: str | None = None, + start_time: datetime | None = None, + end_time: datetime | None = None, + ) -> tuple[list[OperationLogORM], int]: + query = self.session.query(OperationLogORM) + + if username: + query = query.filter(OperationLogORM.username.like(f"%{username}%")) + if module: + query = query.filter(OperationLogORM.module == module) + if action: + query = query.filter(OperationLogORM.action == action) + if status: + query = query.filter(OperationLogORM.status == status) + if start_time: + query = query.filter(OperationLogORM.created_at >= start_time) + if end_time: + query = query.filter(OperationLogORM.created_at <= end_time) + if keyword: + like_keyword = f"%{keyword}%" + query = query.filter( + or_( + OperationLogORM.username.like(like_keyword), + OperationLogORM.module.like(like_keyword), + OperationLogORM.action.like(like_keyword), + OperationLogORM.target_type.like(like_keyword), + OperationLogORM.target_id.like(like_keyword), + OperationLogORM.detail.like(like_keyword), + ) + ) + + total = query.count() + items = ( + query.order_by(OperationLogORM.created_at.desc(), OperationLogORM.id.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + return items, total + + def rollback(self) -> None: + self.session.rollback() diff --git a/financial_system/repositories/organization_repository.py b/financial_system/repositories/organization_repository.py new file mode 100644 index 0000000..b40a303 --- /dev/null +++ b/financial_system/repositories/organization_repository.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +from sqlalchemy import or_ +from sqlalchemy.orm import Session, joinedload + +from ..database.orm import DepartmentORM, PositionORM + + +class OrganizationRepository: + """组织架构仓库:封装部门和岗位表的读写。""" + + def __init__(self, session: Session): + self.session = session + + def list_departments(self, *, active_only: bool = False, keyword: str | None = None) -> list[DepartmentORM]: + query = self.session.query(DepartmentORM) + if active_only: + query = query.filter(DepartmentORM.is_active.is_(True)) + if keyword: + like_keyword = f"%{keyword}%" + query = query.filter( + or_( + DepartmentORM.department_code.like(like_keyword), + DepartmentORM.name.like(like_keyword), + ) + ) + return query.order_by(DepartmentORM.sort_order.asc(), DepartmentORM.id.asc()).all() + + def create_department( + self, + *, + parent_id: int | None, + department_code: str, + name: str, + sort_order: int, + is_active: bool, + remark: str, + ) -> DepartmentORM: + department = DepartmentORM( + parent_id=parent_id, + department_code=department_code, + name=name, + sort_order=sort_order, + is_active=is_active, + remark=remark, + ) + self.session.add(department) + self.session.commit() + self.session.refresh(department) + return department + + def update_department( + self, + department_id: int, + *, + parent_id: int | None, + department_code: str, + name: str, + sort_order: int, + is_active: bool, + remark: str, + ) -> DepartmentORM: + department = self.require_department(department_id) + department.parent_id = parent_id + department.department_code = department_code + department.name = name + department.sort_order = sort_order + department.is_active = is_active + department.remark = remark + self.session.commit() + self.session.refresh(department) + return department + + def require_department(self, department_id: int) -> DepartmentORM: + department = self.session.query(DepartmentORM).filter(DepartmentORM.id == department_id).one_or_none() + if department is None: + raise ValueError("部门不存在") + return department + + def get_department_by_name(self, name: str) -> DepartmentORM | None: + return self.session.query(DepartmentORM).filter(DepartmentORM.name == name).one_or_none() + + def get_department_by_code(self, department_code: str) -> DepartmentORM | None: + return ( + self.session.query(DepartmentORM) + .filter(DepartmentORM.department_code == department_code) + .one_or_none() + ) + + def list_positions( + self, + *, + department_id: int | None = None, + active_only: bool = False, + keyword: str | None = None, + ) -> list[PositionORM]: + query = self.session.query(PositionORM).join(PositionORM.department).options(joinedload(PositionORM.department)) + if department_id: + query = query.filter(PositionORM.department_id == department_id) + if active_only: + query = query.filter(PositionORM.is_active.is_(True)) + if keyword: + like_keyword = f"%{keyword}%" + query = query.filter( + or_( + PositionORM.position_code.like(like_keyword), + PositionORM.name.like(like_keyword), + ) + ) + return query.order_by( + DepartmentORM.sort_order.asc(), + PositionORM.sort_order.asc(), + PositionORM.id.asc(), + ).all() + + def create_position( + self, + *, + department_id: int, + position_code: str, + name: str, + sort_order: int, + is_active: bool, + remark: str, + ) -> PositionORM: + position = PositionORM( + department_id=department_id, + position_code=position_code, + name=name, + sort_order=sort_order, + is_active=is_active, + remark=remark, + ) + self.session.add(position) + self.session.commit() + return self.require_position(position.id) + + def update_position( + self, + position_id: int, + *, + department_id: int, + position_code: str, + name: str, + sort_order: int, + is_active: bool, + remark: str, + ) -> PositionORM: + position = self.require_position(position_id) + position.department_id = department_id + position.position_code = position_code + position.name = name + position.sort_order = sort_order + position.is_active = is_active + position.remark = remark + self.session.commit() + return self.require_position(position_id) + + def require_position(self, position_id: int) -> PositionORM: + position = ( + self.session.query(PositionORM) + .options(joinedload(PositionORM.department)) + .filter(PositionORM.id == position_id) + .one_or_none() + ) + if position is None: + raise ValueError("岗位不存在") + return position + + def get_position_by_code(self, position_code: str) -> PositionORM | None: + return ( + self.session.query(PositionORM) + .options(joinedload(PositionORM.department)) + .filter(PositionORM.position_code == position_code) + .one_or_none() + ) + + def get_position_by_department_and_name(self, department_id: int, name: str) -> PositionORM | None: + """按部门和岗位名称查询,用于避免同一部门下重复维护同名岗位。""" + return ( + self.session.query(PositionORM) + .options(joinedload(PositionORM.department)) + .filter(PositionORM.department_id == department_id, PositionORM.name == name) + .one_or_none() + ) diff --git a/financial_system/repositories/payroll_repository.py b/financial_system/repositories/payroll_repository.py new file mode 100644 index 0000000..773abba --- /dev/null +++ b/financial_system/repositories/payroll_repository.py @@ -0,0 +1,300 @@ +from __future__ import annotations + +from uuid import uuid4 + +from sqlalchemy.orm import Session, selectinload + +from ..database.orm import ( + AttendanceRecordORM, + EmployeeORM, + LeaveRecordORM, + OvertimeRecordORM, + PayrollJobORM, + PayrollResultORM, + SalaryDetailORM, + SalaryRecordORM, +) +from ..domain.models import PayrollResult + + +class PayrollRepository: + """工资任务仓库:只负责 Payroll ORM 的读写,不包含业务计算。""" + + def __init__(self, session: Session): + self.session = session + + def create_job(self, source_type: str, input_file: str | None = None) -> PayrollJobORM: + job = PayrollJobORM( + id=uuid4().hex, + source_type=source_type, + status="running", + input_file=input_file, + ) + self.session.add(job) + self.session.commit() + self.session.refresh(job) + return job + + def save_results(self, job_id: str, results: list[PayrollResult]) -> None: + """批量保存员工结果,保持计算服务和 ORM 字段映射隔离。""" + job = self.require_job(job_id) + rows = [self._result_row(job_id, result) for result in results] + normalized_rows = [] + salary_rows = [] + for result in results: + employee = self._match_employee(result) + normalized_rows.extend(self._attendance_rows(job.id, job.source_type, result, employee)) + normalized_rows.extend(self._leave_rows(job.id, job.source_type, result, employee)) + normalized_rows.extend(self._overtime_rows(job.id, job.source_type, result, employee)) + salary_rows.append(self._salary_record_row(job_id, result, employee)) + self.session.add_all([*rows, *normalized_rows, *salary_rows]) + self.session.commit() + + def mark_completed( + self, + job_id: str, + employee_count: int, + output_file: str | None = None, + ) -> PayrollJobORM: + job = self.require_job(job_id) + job.status = "completed" + job.employee_count = employee_count + job.output_file = output_file + self.session.commit() + self.session.refresh(job) + return job + + def mark_failed(self, job_id: str, error_message: str) -> PayrollJobORM: + job = self.require_job(job_id) + job.status = "failed" + job.error_message = error_message + self.session.commit() + self.session.refresh(job) + return job + + def get_job(self, job_id: str) -> PayrollJobORM | None: + return ( + self.session.query(PayrollJobORM) + .options(selectinload(PayrollJobORM.results)) + .filter(PayrollJobORM.id == job_id) + .one_or_none() + ) + + def require_job(self, job_id: str) -> PayrollJobORM: + job = self.get_job(job_id) + if job is None: + raise KeyError(f"Payroll job not found: {job_id}") + return job + + def _result_row(self, job_id: str, result: PayrollResult) -> PayrollResultORM: + employee = result.employee + return PayrollResultORM( + job_id=job_id, + name=employee.name, + department=employee.department, + position=employee.position, + attendance_group=employee.attendance_group, + salary_month=result.salary_month, + salary_mode=result.salary_mode, + attendance_days=result.attendance_days, + absence_days=result.absence_days, + actual_work_hours=result.actual_work_hours, + punch_overtime_hours=result.punch_overtime_hours, + leave_hours=result.leave_hours, + remaining_overtime_hours=result.remaining_overtime_hours, + workday_overtime_hours=result.workday_overtime_hours, + weekend_overtime_hours=result.weekend_overtime_hours, + holiday_overtime_hours=result.holiday_overtime_hours, + minor_late_count=result.minor_late_count, + major_late_count=result.major_late_count, + penalized_late_count=result.penalized_late_count, + late_deduction=result.late_deduction, + missing_card_count=result.missing_card_count, + missing_card_deduction=result.missing_card_deduction, + total_deduction=result.total_deduction, + base_salary=result.base_salary, + base_pay=result.base_pay, + commission_amount=result.commission_amount, + overtime_rate=result.overtime_rate, + weekend_overtime_rate=result.weekend_overtime_rate, + holiday_overtime_rate=result.holiday_overtime_rate, + overtime_pay=result.overtime_pay, + gross_salary=result.gross_salary, + net_salary=result.net_salary, + note=result.note, + ) + + def _match_employee(self, result: PayrollResult) -> EmployeeORM | None: + employee = result.employee + query = self.session.query(EmployeeORM) + if employee.employee_no: + matched = query.filter(EmployeeORM.employee_no == employee.employee_no).one_or_none() + if matched is not None: + return matched + return query.filter(EmployeeORM.name == employee.name).one_or_none() + + def _attendance_rows( + self, + job_id: str, + source_type: str, + result: PayrollResult, + employee: EmployeeORM | None, + ) -> list[AttendanceRecordORM]: + rows: list[AttendanceRecordORM] = [] + for record in result.employee.daily_records: + rows.append( + AttendanceRecordORM( + job_id=job_id, + employee_id=employee.id if employee else None, + employee_no=result.employee.employee_no, + employee_name=result.employee.name, + department=result.employee.department, + work_date=record.work_date, + first_punch=_time_text(record.first_punch), + last_punch=_time_text(record.last_punch), + attendance_status=_attendance_status(record), + late_minutes=sum(record.late_minutes), + missing_card_count=record.missing_card_count, + leave_hours=record.leave_hours_in_cell, + overtime_hours=record.punch_overtime_hours, + raw_text=record.raw_text, + source_type=source_type, + ) + ) + return rows + + def _leave_rows( + self, + job_id: str, + source_type: str, + result: PayrollResult, + employee: EmployeeORM | None, + ) -> list[LeaveRecordORM]: + return [ + LeaveRecordORM( + job_id=job_id, + employee_id=employee.id if employee else None, + employee_no=result.employee.employee_no, + employee_name=result.employee.name, + leave_type=event.kind, + start_text=event.start_text, + end_text=event.end_text, + hours=event.hours, + source_type=source_type, + status="approved", + ) + for event in result.employee.unique_leave_events + ] + + def _overtime_rows( + self, + job_id: str, + source_type: str, + result: PayrollResult, + employee: EmployeeORM | None, + ) -> list[OvertimeRecordORM]: + rows: list[OvertimeRecordORM] = [] + for record in result.employee.daily_records: + if record.punch_overtime_hours <= 0: + continue + overtime_type = _overtime_type(record.work_date) + rate = result.weekend_overtime_rate if overtime_type == "weekend" else result.overtime_rate + rows.append( + OvertimeRecordORM( + job_id=job_id, + employee_id=employee.id if employee else None, + employee_no=result.employee.employee_no, + employee_name=result.employee.name, + work_date=record.work_date, + overtime_type=overtime_type, + hours=record.punch_overtime_hours, + rate=rate, + amount=round(record.punch_overtime_hours * rate, 2), + source_type=source_type, + ) + ) + return rows + + def _salary_record_row( + self, + job_id: str, + result: PayrollResult, + employee: EmployeeORM | None, + ) -> SalaryRecordORM: + record = SalaryRecordORM( + job_id=job_id, + employee_id=employee.id if employee else None, + employee_no=result.employee.employee_no, + employee_name=result.employee.name, + department=result.employee.department, + salary_month=result.salary_month, + salary_mode=result.salary_mode, + base_pay=result.base_pay or 0, + commission_amount=result.commission_amount, + overtime_pay=result.overtime_pay, + deduction_amount=result.total_deduction, + gross_salary=result.gross_salary or 0, + net_salary=result.net_salary or 0, + remark=result.note, + ) + record.details = [ + SalaryDetailORM(item_type="earning", item_name="基础工资", amount=result.base_pay or 0), + SalaryDetailORM(item_type="earning", item_name="提成/奖金", amount=result.commission_amount), + SalaryDetailORM( + item_type="earning", + item_name="工作日加班费", + amount=round(result.workday_overtime_hours * result.overtime_rate, 2), + quantity=result.workday_overtime_hours, + unit_price=result.overtime_rate, + ), + SalaryDetailORM( + item_type="earning", + item_name="周末加班费", + amount=round(result.weekend_overtime_hours * result.weekend_overtime_rate, 2), + quantity=result.weekend_overtime_hours, + unit_price=result.weekend_overtime_rate, + ), + SalaryDetailORM( + item_type="earning", + item_name="节假日加班费", + amount=round(result.holiday_overtime_hours * result.holiday_overtime_rate, 2), + quantity=result.holiday_overtime_hours, + unit_price=result.holiday_overtime_rate, + ), + SalaryDetailORM( + item_type="deduction", + item_name="迟到扣款", + amount=result.late_deduction, + quantity=result.penalized_late_count, + ), + SalaryDetailORM( + item_type="deduction", + item_name="缺卡扣款", + amount=result.missing_card_deduction, + quantity=result.missing_card_count, + ), + SalaryDetailORM(item_type="stat", item_name="出勤天数", amount=0, quantity=result.attendance_days), + SalaryDetailORM(item_type="stat", item_name="请假工时", amount=0, quantity=result.leave_hours), + SalaryDetailORM(item_type="stat", item_name="剩余加班工时", amount=0, quantity=result.remaining_overtime_hours), + ] + return record + + +def _time_text(value) -> str: + if value is None: + return "" + return value.strftime("%H:%M") + + +def _attendance_status(record) -> str: + if record.first_punch or record.last_punch: + if record.missing_card_count: + return "missing" + return "present" + if record.leave_hours_in_cell: + return "leave" + return "absent" + + +def _overtime_type(work_date) -> str: + return "weekend" if work_date.weekday() >= 5 else "workday" diff --git a/financial_system/repositories/report_repository.py b/financial_system/repositories/report_repository.py new file mode 100644 index 0000000..fa20424 --- /dev/null +++ b/financial_system/repositories/report_repository.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from ..database.orm import AttendanceRecordORM, SalaryRecordORM + + +class ReportRepository: + """报表仓库:只负责聚合工资、考勤等已落库数据。""" + + def __init__(self, session: Session): + self.session = session + + def payroll_totals(self, *, salary_month: str | None = None) -> dict[str, float]: + query = self.session.query( + func.count(SalaryRecordORM.id), + func.sum(SalaryRecordORM.base_pay), + func.sum(SalaryRecordORM.commission_amount), + func.sum(SalaryRecordORM.overtime_pay), + func.sum(SalaryRecordORM.deduction_amount), + func.sum(SalaryRecordORM.gross_salary), + func.sum(SalaryRecordORM.net_salary), + ) + if salary_month: + query = query.filter(SalaryRecordORM.salary_month == salary_month) + row = query.one() + return { + "employee_count": int(row[0] or 0), + "base_pay": float(row[1] or 0), + "commission_amount": float(row[2] or 0), + "overtime_pay": float(row[3] or 0), + "deduction_amount": float(row[4] or 0), + "gross_salary": float(row[5] or 0), + "net_salary": float(row[6] or 0), + } + + def attendance_totals(self, *, salary_month: str | None = None) -> dict[str, float]: + query = self.session.query( + func.count(AttendanceRecordORM.id), + func.sum(AttendanceRecordORM.overtime_hours), + func.sum(AttendanceRecordORM.leave_hours), + func.sum(AttendanceRecordORM.missing_card_count), + ) + if salary_month: + query = query.filter(func.date_format(AttendanceRecordORM.work_date, "%Y-%m") == salary_month) + row = query.one() + return { + "attendance_record_count": int(row[0] or 0), + "overtime_hours": float(row[1] or 0), + "leave_hours": float(row[2] or 0), + "missing_card_count": float(row[3] or 0), + } + + def department_totals(self, *, salary_month: str | None = None) -> list[dict[str, float | str]]: + query = self.session.query( + SalaryRecordORM.department, + func.count(SalaryRecordORM.id), + func.sum(SalaryRecordORM.gross_salary), + func.sum(SalaryRecordORM.net_salary), + ) + if salary_month: + query = query.filter(SalaryRecordORM.salary_month == salary_month) + rows = query.group_by(SalaryRecordORM.department).order_by(func.sum(SalaryRecordORM.net_salary).desc()).all() + return [ + { + "department": department or "未分配", + "employee_count": int(count or 0), + "gross_salary": float(gross or 0), + "net_salary": float(net or 0), + } + for department, count, gross, net in rows + ] + + def recent_salary_records(self, *, salary_month: str | None = None, limit: int = 10) -> list[SalaryRecordORM]: + query = self.session.query(SalaryRecordORM) + if salary_month: + query = query.filter(SalaryRecordORM.salary_month == salary_month) + return query.order_by(SalaryRecordORM.created_at.desc(), SalaryRecordORM.id.desc()).limit(limit).all() diff --git a/financial_system/repositories/salary_config_repository.py b/financial_system/repositories/salary_config_repository.py new file mode 100644 index 0000000..e86e745 --- /dev/null +++ b/financial_system/repositories/salary_config_repository.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from sqlalchemy.orm import Session + +from ..database.orm import SalaryConfigORM + + +class SalaryConfigRepository: + """系统配置仓库:封装 salary_config 表的查询和保存。""" + + def __init__(self, session: Session): + self.session = session + + def list_configs(self, *, config_group: str | None = None) -> list[SalaryConfigORM]: + query = self.session.query(SalaryConfigORM) + if config_group: + query = query.filter(SalaryConfigORM.config_group == config_group) + return query.order_by(SalaryConfigORM.config_group.asc(), SalaryConfigORM.id.asc()).all() + + def get_by_key(self, config_key: str) -> SalaryConfigORM | None: + return self.session.query(SalaryConfigORM).filter(SalaryConfigORM.config_key == config_key).one_or_none() + + def require_by_key(self, config_key: str) -> SalaryConfigORM: + config = self.get_by_key(config_key) + if config is None: + raise ValueError("配置项不存在") + return config + + def upsert_config( + self, + *, + config_key: str, + config_name: str, + config_group: str, + config_value: str, + value_type: str, + is_enabled: bool = True, + remark: str = "", + ) -> SalaryConfigORM: + config = self.get_by_key(config_key) + if config is None: + config = SalaryConfigORM(config_key=config_key) + self.session.add(config) + config.config_name = config_name + config.config_group = config_group + config.config_value = config_value + config.value_type = value_type + config.is_enabled = is_enabled + config.remark = remark + self.session.commit() + self.session.refresh(config) + return config + + def create_defaults(self, defaults: list[dict]) -> int: + created = 0 + for item in defaults: + if self.get_by_key(item["config_key"]) is not None: + continue + self.session.add(SalaryConfigORM(**item)) + created += 1 + if created: + self.session.commit() + return created diff --git a/financial_system/repositories/salary_profile_repository.py b/financial_system/repositories/salary_profile_repository.py new file mode 100644 index 0000000..6e357b9 --- /dev/null +++ b/financial_system/repositories/salary_profile_repository.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from sqlalchemy import or_ +from sqlalchemy.orm import Session, joinedload + +from ..database.orm import EmployeeORM, SalaryProfileORM + + +class SalaryProfileRepository: + """薪资档案仓库:维护 salary_profiles 表和员工关联查询。""" + + def __init__(self, session: Session): + self.session = session + + def upsert_profile( + self, + *, + employee_id: int, + salary_mode: str, + base_salary: float, + hourly_rate: float, + piece_quantity: float, + piece_unit_price: float, + probation_type: str, + probation_ratio: float, + probation_salary: float, + commission_amount: float, + overtime_rate: float, + weekend_overtime_rate: float, + holiday_overtime_rate: float, + effective_month: str = "", + remark: str = "", + ) -> SalaryProfileORM: + profile = self.get_by_employee_id(employee_id) + if profile is None: + profile = SalaryProfileORM(employee_id=employee_id) + self.session.add(profile) + + profile.salary_mode = salary_mode + profile.base_salary = base_salary + profile.hourly_rate = hourly_rate + profile.piece_quantity = piece_quantity + profile.piece_unit_price = piece_unit_price + profile.probation_type = probation_type + profile.probation_ratio = probation_ratio + profile.probation_salary = probation_salary + profile.commission_amount = commission_amount + profile.overtime_rate = overtime_rate + profile.weekend_overtime_rate = weekend_overtime_rate + profile.holiday_overtime_rate = holiday_overtime_rate + profile.effective_month = effective_month + profile.remark = remark + self.session.commit() + self.session.refresh(profile) + return self.require_by_employee_id(employee_id) + + def get_by_employee_id(self, employee_id: int) -> SalaryProfileORM | None: + return ( + self.session.query(SalaryProfileORM) + .options(joinedload(SalaryProfileORM.employee)) + .filter(SalaryProfileORM.employee_id == employee_id) + .one_or_none() + ) + + def require_by_employee_id(self, employee_id: int) -> SalaryProfileORM: + profile = self.get_by_employee_id(employee_id) + if profile is None: + raise ValueError("薪资档案不存在") + return profile + + def list_profiles(self, *, keyword: str | None = None) -> list[SalaryProfileORM]: + query = self.session.query(SalaryProfileORM).options(joinedload(SalaryProfileORM.employee)) + if keyword: + like_keyword = f"%{keyword}%" + query = query.join(SalaryProfileORM.employee).filter( + or_( + EmployeeORM.employee_no.like(like_keyword), + EmployeeORM.name.like(like_keyword), + EmployeeORM.department.like(like_keyword), + EmployeeORM.position.like(like_keyword), + ) + ) + return query.order_by(SalaryProfileORM.updated_at.desc(), SalaryProfileORM.id.desc()).all() + + def list_active_profiles(self) -> list[SalaryProfileORM]: + return ( + self.session.query(SalaryProfileORM) + .join(SalaryProfileORM.employee) + .options(joinedload(SalaryProfileORM.employee)) + .filter(EmployeeORM.employment_status == "active") + .order_by(SalaryProfileORM.id.asc()) + .all() + ) diff --git a/financial_system/repositories/user_repository.py b/financial_system/repositories/user_repository.py new file mode 100644 index 0000000..f7c6675 --- /dev/null +++ b/financial_system/repositories/user_repository.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from sqlalchemy.orm import Session + +from ..database.orm import UserORM + + +class UserRepository: + """用户仓库:封装 users 表 CRUD,供认证服务调用。""" + + def __init__(self, session: Session): + self.session = session + + def create_user( + self, + *, + username: str, + password_hash: str, + role: str, + display_name: str = "", + avatar_url: str = "", + email: str = "", + phone: str = "", + department: str = "", + position_title: str = "", + is_active: bool = True, + ) -> UserORM: + user = UserORM( + username=username, + password_hash=password_hash, + role=role, + display_name=display_name, + avatar_url=avatar_url, + email=email, + phone=phone, + department=department, + position_title=position_title, + is_active=is_active, + ) + self.session.add(user) + self.session.commit() + self.session.refresh(user) + return user + + def get_by_username(self, username: str) -> UserORM | None: + return self.session.query(UserORM).filter(UserORM.username == username).one_or_none() + + def get_by_id(self, user_id: int) -> UserORM | None: + return self.session.query(UserORM).filter(UserORM.id == user_id).one_or_none() + + def list_users(self) -> list[UserORM]: + return self.session.query(UserORM).order_by(UserORM.id.asc()).all() + + def has_users(self) -> bool: + return self.session.query(UserORM.id).first() is not None + + def update_profile( + self, + user_id: int, + *, + display_name: str | None = None, + avatar_url: str | None = None, + email: str | None = None, + phone: str | None = None, + department: str | None = None, + position_title: str | None = None, + ) -> UserORM: + user = self.get_by_id(user_id) + if user is None: + raise ValueError("用户不存在") + + values = { + "display_name": display_name, + "avatar_url": avatar_url, + "email": email, + "phone": phone, + "department": department, + "position_title": position_title, + } + for field, value in values.items(): + if value is not None: + setattr(user, field, value) + + self.session.commit() + self.session.refresh(user) + return user + + def update_password_hash(self, user_id: int, password_hash: str) -> UserORM: + user = self.get_by_id(user_id) + if user is None: + raise ValueError("用户不存在") + + user.password_hash = password_hash + self.session.commit() + self.session.refresh(user) + return user diff --git a/financial_system/services/__init__.py b/financial_system/services/__init__.py new file mode 100644 index 0000000..a52fe7a --- /dev/null +++ b/financial_system/services/__init__.py @@ -0,0 +1,28 @@ +from .auth_service import AuthService, AuthSession +from .commission_service import CommissionPage, CommissionService +from .employee_service import EmployeePage, EmployeeService +from .operation_log_service import OperationLogPage, OperationLogService +from .organization_service import OrganizationService +from .payroll_service import PayrollApplicationService, PayrollComputation, PayrollService +from .report_service import PayrollReport, ReportService +from .salary_config_service import SalaryConfigService +from .salary_profile_service import SalaryProfileService + +__all__ = [ + "AuthService", + "AuthSession", + "CommissionPage", + "CommissionService", + "EmployeePage", + "EmployeeService", + "OperationLogPage", + "OperationLogService", + "OrganizationService", + "PayrollReport", + "PayrollApplicationService", + "PayrollComputation", + "PayrollService", + "ReportService", + "SalaryConfigService", + "SalaryProfileService", +] diff --git a/financial_system/services/auth_service.py b/financial_system/services/auth_service.py new file mode 100644 index 0000000..f7c8c1d --- /dev/null +++ b/financial_system/services/auth_service.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import BinaryIO + +from ..core.logger import AppLogger +from ..core.permissions import VALID_ROLES +from ..core.security import create_access_token, hash_password, verify_password +from ..core.settings import AppSettings +from ..database.orm import UserORM +from ..io.storage import FileStorage +from ..repositories import UserRepository + +logger = AppLogger.get_logger(__name__) + + +@dataclass(frozen=True) +class AuthSession: + """登录成功后的短生命周期对象,只承载用户和签发出的 token。""" + + user: UserORM + access_token: str + token_type: str = "bearer" + + +class AuthService: + """认证应用服务:登录、创建用户和首次启动超级用户初始化。""" + + def __init__(self, repository: UserRepository, settings: AppSettings): + self.repository = repository + self.settings = settings + self.storage = FileStorage(settings) + + def login(self, username: str, password: str) -> AuthSession: + logger.info("用户登录校验开始 username=%s", username) + user = self.repository.get_by_username(username) + if user is None or not user.is_active: + logger.error("用户登录失败 username=%s reason=user_not_found_or_inactive", username) + raise ValueError("用户名或密码错误") + if not verify_password(password, user.password_hash): + logger.error("用户登录失败 username=%s reason=invalid_password", username) + raise ValueError("用户名或密码错误") + token = create_access_token( + user_id=user.id, + username=user.username, + role=user.role, + secret_key=self.settings.auth_secret_key, + expires_minutes=self.settings.access_token_expire_minutes, + ) + logger.info("用户登录成功 user_id=%s username=%s role=%s", user.id, user.username, user.role) + return AuthSession(user=user, access_token=token) + + def create_user( + self, + *, + username: str, + password: str, + role: str, + display_name: str = "", + email: str = "", + phone: str = "", + department: str = "", + position_title: str = "", + ) -> UserORM: + logger.info("创建用户开始 username=%s role=%s", username, role) + if role not in VALID_ROLES: + logger.error("创建用户失败 username=%s reason=invalid_role role=%s", username, role) + raise ValueError(f"无效角色:{role}") + if self.repository.get_by_username(username): + logger.error("创建用户失败 username=%s reason=duplicate_username", username) + raise ValueError("用户名已存在") + user = self.repository.create_user( + username=username, + password_hash=hash_password(password), + role=role, + display_name=display_name, + email=email, + phone=phone, + department=department, + position_title=position_title, + ) + logger.info("创建用户成功 user_id=%s username=%s role=%s", user.id, user.username, user.role) + return user + + def update_profile( + self, + user_id: int, + *, + display_name: str, + email: str, + phone: str, + department: str, + position_title: str, + ) -> UserORM: + logger.info("修改个人资料开始 user_id=%s", user_id) + user = self.repository.update_profile( + user_id, + display_name=display_name, + email=email, + phone=phone, + department=department, + position_title=position_title, + ) + logger.info("修改个人资料成功 user_id=%s username=%s", user.id, user.username) + return user + + def update_avatar(self, user_id: int, filename: str, stream: BinaryIO) -> UserORM: + logger.info("修改头像开始 user_id=%s filename=%s", user_id, filename) + avatar_url = self.storage.save_avatar(filename, stream) + user = self.repository.update_profile(user_id, avatar_url=avatar_url) + logger.info("修改头像成功 user_id=%s avatar_url=%s", user.id, avatar_url) + return user + + def change_password( + self, + user_id: int, + *, + old_password: str, + new_password: str, + confirm_password: str, + ) -> UserORM: + logger.info("修改密码开始 user_id=%s", user_id) + user = self.repository.get_by_id(user_id) + if user is None or not user.is_active: + logger.error("修改密码失败 user_id=%s reason=user_not_found_or_inactive", user_id) + raise ValueError("用户不存在或已停用") + if not verify_password(old_password, user.password_hash): + logger.error("修改密码失败 user_id=%s username=%s reason=invalid_old_password", user.id, user.username) + raise ValueError("原密码错误") + if new_password != confirm_password: + logger.error("修改密码失败 user_id=%s username=%s reason=password_confirm_mismatch", user.id, user.username) + raise ValueError("两次输入的新密码不一致") + if verify_password(new_password, user.password_hash): + logger.error("修改密码失败 user_id=%s username=%s reason=same_as_old_password", user.id, user.username) + raise ValueError("新密码不能与原密码相同") + + updated_user = self.repository.update_password_hash(user.id, hash_password(new_password)) + logger.info("修改密码成功 user_id=%s username=%s", updated_user.id, updated_user.username) + return updated_user + + def list_users(self) -> list[UserORM]: + users = self.repository.list_users() + logger.info("查询用户列表成功 count=%s", len(users)) + return users + + def ensure_bootstrap_superuser(self) -> None: + if self.repository.has_users(): + logger.info("用户表已有数据,跳过初始化超级管理员") + return + self.repository.create_user( + username=self.settings.bootstrap_superuser_username, + password_hash=hash_password(self.settings.bootstrap_superuser_password), + role="superuser", + display_name=self.settings.bootstrap_superuser_display_name, + ) + logger.info("默认超级管理员初始化完成 username=%s", self.settings.bootstrap_superuser_username) diff --git a/financial_system/services/commission_service.py b/financial_system/services/commission_service.py new file mode 100644 index 0000000..9d8b77d --- /dev/null +++ b/financial_system/services/commission_service.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path +from typing import BinaryIO + +from openpyxl import load_workbook + +from ..core.logger import AppLogger +from ..database.orm import CommissionRecordORM +from ..io.storage import FileStorage +from ..repositories import CommissionRepository, EmployeeRepository + +logger = AppLogger.get_logger(__name__) +MONTH_RE = re.compile(r"^\d{4}-\d{2}$") + + +@dataclass(frozen=True) +class CommissionPage: + items: list[CommissionRecordORM] + total: int + page: int + page_size: int + + +class CommissionService: + """提成应用服务:封装手工维护、Excel导入和月度汇总。""" + + def __init__( + self, + repository: CommissionRepository, + employee_repository: EmployeeRepository, + storage: FileStorage | None = None, + ): + self.repository = repository + self.employee_repository = employee_repository + self.storage = storage + + def list_records( + self, + *, + page: int, + page_size: int, + commission_month: str | None = None, + keyword: str | None = None, + ) -> CommissionPage: + month = _clean_optional(commission_month) + if month: + _validate_month(month) + items, total = self.repository.list_records( + page=page, + page_size=page_size, + commission_month=month, + keyword=_clean_optional(keyword), + ) + logger.info("查询提成记录成功 total=%s page=%s page_size=%s", total, page, page_size) + return CommissionPage(items=items, total=total, page=page, page_size=page_size) + + def create_record(self, **values) -> CommissionRecordORM: + values = self._validated_values(values) + record = self.repository.create_record(**values) + logger.info("新增提成记录成功 record_id=%s employee_id=%s amount=%s", record.id, record.employee_id, record.amount) + return record + + def update_record(self, record_id: int, **values) -> CommissionRecordORM: + values = self._validated_values(values) + record = self.repository.update_record(record_id, **values) + logger.info("更新提成记录成功 record_id=%s employee_id=%s amount=%s", record.id, record.employee_id, record.amount) + return record + + def delete_record(self, record_id: int) -> None: + self.repository.delete_record(record_id) + logger.info("删除提成记录成功 record_id=%s", record_id) + + def aggregate_by_employee_id(self, commission_month: str) -> dict[int, float]: + _validate_month(commission_month) + return self.repository.aggregate_by_month(commission_month) + + def import_excel(self, filename: str, stream: BinaryIO) -> int: + if self.storage is None: + raise ValueError("文件存储未初始化") + upload_path = self.storage.save_upload(filename, stream) + count = self._import_from_path(upload_path) + logger.info("提成Excel导入完成 filename=%s count=%s", filename, count) + return count + + def _validated_values(self, values: dict) -> dict: + employee_id = int(values.get("employee_id") or 0) + self.employee_repository.require_by_id(employee_id) + commission_month = _normalize_month(str(values.get("commission_month") or "").strip()) + _validate_month(commission_month) + amount = float(values.get("amount") or 0) + if amount < 0: + raise ValueError("提成金额不能小于0") + return { + "employee_id": employee_id, + "commission_month": commission_month, + "commission_type": str(values.get("commission_type") or "销售提成").strip() or "销售提成", + "amount": amount, + "source_type": str(values.get("source_type") or "manual").strip() or "manual", + "business_ref": str(values.get("business_ref") or "").strip(), + "remark": str(values.get("remark") or "").strip(), + } + + def _import_from_path(self, path: Path) -> int: + workbook = load_workbook(path, data_only=True) + sheet = workbook.active + headers = { + str(sheet.cell(1, col_idx).value or "").strip(): col_idx + for col_idx in range(1, sheet.max_column + 1) + } + required = ("月份", "金额") + missing = [name for name in required if name not in headers] + if missing: + raise ValueError(f"提成导入缺少必要表头:{', '.join(missing)}") + + rows: list[CommissionRecordORM] = [] + for row_idx in range(2, sheet.max_row + 1): + month = _normalize_month(_cell(sheet, row_idx, headers.get("月份"))) + amount_text = _cell(sheet, row_idx, headers.get("金额")) + if not month and not amount_text: + continue + employee = self._employee_from_row(sheet, row_idx, headers) + if employee is None: + raise ValueError(f"第 {row_idx} 行未匹配到员工,请提供员工编号或姓名") + _validate_month(month) + amount = float(amount_text or 0) + if amount < 0: + raise ValueError(f"第 {row_idx} 行提成金额不能小于0") + rows.append( + CommissionRecordORM( + employee_id=employee.id, + commission_month=month, + commission_type=_cell(sheet, row_idx, headers.get("提成类型")) or "销售提成", + amount=amount, + source_type="excel", + business_ref=_cell(sheet, row_idx, headers.get("业务单号")), + remark=_cell(sheet, row_idx, headers.get("备注")), + ) + ) + return self.repository.create_many(rows) if rows else 0 + + def _employee_from_row(self, sheet, row_idx: int, headers: dict[str, int]): + employee_no = _cell(sheet, row_idx, headers.get("员工编号")) + if employee_no: + employee = self.employee_repository.get_by_employee_no(employee_no) + if employee is not None: + return employee + name = _cell(sheet, row_idx, headers.get("姓名")) + if name: + matches = self.employee_repository.list_employees(keyword=name, employment_status="active") + exact = [employee for employee in matches if employee.name == name] + if len(exact) == 1: + return exact[0] + return None + + +def _validate_month(value: str) -> None: + if not MONTH_RE.match(value): + raise ValueError("月份格式必须为YYYY-MM") + + +def _normalize_month(value: str) -> str: + text = value.strip().replace("/", "-") + if re.match(r"^\d{4}-\d{2}(-\d{2})?", text): + return text[:7] + return text + + +def _clean_optional(value: str | None) -> str | None: + if value is None: + return None + stripped = value.strip() + return stripped or None + + +def _cell(sheet, row_idx: int, col_idx: int | None) -> str: + if not col_idx: + return "" + value = sheet.cell(row_idx, col_idx).value + return "" if value is None else str(value).strip() diff --git a/financial_system/services/employee_service.py b/financial_system/services/employee_service.py new file mode 100644 index 0000000..29083c5 --- /dev/null +++ b/financial_system/services/employee_service.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass + +from ..core.logger import AppLogger +from ..database.orm import EmployeeORM +from ..repositories import EmployeeRepository + +logger = AppLogger.get_logger(__name__) + +VALID_EMPLOYMENT_STATUS = {"active", "inactive"} +EMPLOYEE_NO_PREFIX = "ZA" +EMPLOYEE_NO_WIDTH = 4 + + +@dataclass(frozen=True) +class EmployeePage: + """员工分页结果。""" + + items: list[EmployeeORM] + total: int + page: int + page_size: int + + +class EmployeeService: + """员工应用服务:处理员工档案维护校验。""" + + def __init__(self, repository: EmployeeRepository): + self.repository = repository + + def preview_next_employee_no(self) -> str: + """预览下一个员工编号,实际新增时仍以后端最终生成为准。""" + employee_no = self._next_employee_no() + logger.info("预览下一个员工编号 employee_no=%s", employee_no) + return employee_no + + def create_employee( + self, + *, + employee_no: str = "", + name: str, + dingtalk_user_id: str = "", + department: str = "", + position: str = "", + phone: str = "", + employment_status: str = "active", + remark: str = "", + ) -> EmployeeORM: + employee_no = self._next_employee_no() + name = _required(name, "员工姓名") + employment_status = _valid_status(employment_status) + if self.repository.get_by_employee_no(employee_no): + raise ValueError("员工编号已存在") + + employee = self.repository.create_employee( + employee_no=employee_no, + name=name, + dingtalk_user_id=dingtalk_user_id.strip(), + department=department.strip(), + position=position.strip(), + phone=phone.strip(), + employment_status=employment_status, + remark=remark.strip(), + ) + logger.info("员工创建成功 employee_id=%s employee_no=%s name=%s", employee.id, employee.employee_no, employee.name) + return employee + + def update_employee( + self, + employee_id: int, + *, + employee_no: str = "", + name: str | None = None, + dingtalk_user_id: str | None = None, + department: str | None = None, + position: str | None = None, + phone: str | None = None, + employment_status: str | None = None, + remark: str | None = None, + ) -> EmployeeORM: + current = self.repository.require_by_id(employee_id) + employee_no = current.employee_no + clean_name = _required(name if name is not None else current.name, "员工姓名") + clean_status = _valid_status(employment_status if employment_status is not None else current.employment_status) + + employee = self.repository.update_employee( + employee_id, + employee_no=employee_no, + name=clean_name, + dingtalk_user_id=_clean_update_value(dingtalk_user_id, current.dingtalk_user_id), + department=_clean_update_value(department, current.department), + position=_clean_update_value(position, current.position), + phone=_clean_update_value(phone, current.phone), + employment_status=clean_status, + remark=_clean_update_value(remark, current.remark), + ) + logger.info("员工更新成功 employee_id=%s employee_no=%s name=%s", employee.id, employee.employee_no, employee.name) + return employee + + def get_employee(self, employee_id: int) -> EmployeeORM: + employee = self.repository.require_by_id(employee_id) + logger.info("查询员工详情成功 employee_id=%s employee_no=%s", employee.id, employee.employee_no) + return employee + + def list_employees( + self, + *, + keyword: str | None = None, + employment_status: str | None = None, + ) -> list[EmployeeORM]: + clean_status = _clean_optional(employment_status) + if clean_status is not None: + clean_status = _valid_status(clean_status) + employees = self.repository.list_employees( + keyword=_clean_optional(keyword), + employment_status=clean_status, + ) + logger.info("查询员工列表成功 count=%s", len(employees)) + return employees + + def list_employees_page( + self, + *, + page: int, + page_size: int, + keyword: str | None = None, + employment_status: str | None = None, + ) -> EmployeePage: + clean_status = _clean_optional(employment_status) + if clean_status is not None: + clean_status = _valid_status(clean_status) + safe_page = max(page, 1) + safe_page_size = min(max(page_size, 1), 100) + employees, total = self.repository.list_employees_page( + page=safe_page, + page_size=safe_page_size, + keyword=_clean_optional(keyword), + employment_status=clean_status, + ) + logger.info("分页查询员工列表成功 total=%s page=%s page_size=%s", total, safe_page, safe_page_size) + return EmployeePage(items=employees, total=total, page=safe_page, page_size=safe_page_size) + + def _next_employee_no(self) -> str: + existing_numbers = self.repository.list_employee_numbers_by_prefix(EMPLOYEE_NO_PREFIX) + pattern = re.compile(rf"^{re.escape(EMPLOYEE_NO_PREFIX)}(\d+)$") + used_numbers = { + int(match.group(1)) + for employee_no in existing_numbers + if (match := pattern.match(employee_no)) + } + number = 1 + while number in used_numbers: + number += 1 + return f"{EMPLOYEE_NO_PREFIX}{number:0{EMPLOYEE_NO_WIDTH}d}" + + +def _required(value: str, label: str) -> str: + stripped = value.strip() + if not stripped: + raise ValueError(f"{label}不能为空") + return stripped + + +def _clean_update_value(value: str | None, fallback: str) -> str: + if value is None: + return fallback + return value.strip() + + +def _valid_status(value: str) -> str: + stripped = value.strip() or "active" + if stripped not in VALID_EMPLOYMENT_STATUS: + raise ValueError("员工状态只能是 active 或 inactive") + return stripped + + +def _clean_optional(value: str | None) -> str | None: + if value is None: + return None + stripped = value.strip() + return stripped or None diff --git a/financial_system/services/operation_log_service.py b/financial_system/services/operation_log_service.py new file mode 100644 index 0000000..0a5daf2 --- /dev/null +++ b/financial_system/services/operation_log_service.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime + +from ..core.logger import AppLogger +from ..database.orm import OperationLogORM, UserORM +from ..repositories import OperationLogRepository + +logger = AppLogger.get_logger(__name__) + + +@dataclass(frozen=True) +class OperationLogPage: + """操作日志分页结果,供 API schema 统一转换。""" + + items: list[OperationLogORM] + total: int + page: int + page_size: int + + +class OperationLogService: + """操作日志应用服务:记录关键操作并提供分页查询。""" + + def __init__(self, repository: OperationLogRepository): + self.repository = repository + + def record( + self, + *, + actor: UserORM | None, + module: str, + action: str, + status: str, + target_type: str = "", + target_id: str = "", + detail: str = "", + ip_address: str = "", + user_agent: str = "", + ) -> OperationLogORM | None: + """写入操作日志;失败只进错误日志,不阻断原业务请求。""" + try: + log = self.repository.create_log( + user_id=actor.id if actor else None, + username=_clip(actor.username if actor else "", 64), + role=_clip(actor.role if actor else "", 32), + module=_clip(module, 64), + action=_clip(action, 128), + target_type=_clip(target_type, 64), + target_id=_clip(target_id, 128), + status=_clip(status, 32), + detail=_clip(detail, 4000), + ip_address=_clip(ip_address, 64), + user_agent=_clip(user_agent, 512), + ) + logger.info( + "操作日志写入成功 log_id=%s username=%s module=%s action=%s status=%s", + log.id, + log.username, + log.module, + log.action, + log.status, + ) + return log + except Exception: + self.repository.rollback() + logger.exception("操作日志写入失败 module=%s action=%s status=%s", module, action, status) + return None + + def list_logs( + self, + *, + page: int, + page_size: int, + username: str | None = None, + module: str | None = None, + action: str | None = None, + status: str | None = None, + keyword: str | None = None, + start_time: datetime | None = None, + end_time: datetime | None = None, + ) -> OperationLogPage: + safe_page = max(page, 1) + safe_page_size = min(max(page_size, 1), 100) + clean_username = _clean_optional(username) + clean_module = _clean_optional(module) + clean_action = _clean_optional(action) + clean_status = _clean_optional(status) + clean_keyword = _clean_optional(keyword) + items, total = self.repository.list_logs( + page=safe_page, + page_size=safe_page_size, + username=clean_username, + module=clean_module, + action=clean_action, + status=clean_status, + keyword=clean_keyword, + start_time=start_time, + end_time=end_time, + ) + logger.info("查询操作日志成功 total=%s page=%s page_size=%s", total, safe_page, safe_page_size) + return OperationLogPage(items=items, total=total, page=safe_page, page_size=safe_page_size) + + +def _clean_optional(value: str | None) -> str | None: + if value is None: + return None + stripped = value.strip() + return stripped or None + + +def _clip(value: str, max_length: int) -> str: + return value[:max_length] diff --git a/financial_system/services/organization_service.py b/financial_system/services/organization_service.py new file mode 100644 index 0000000..81555a2 --- /dev/null +++ b/financial_system/services/organization_service.py @@ -0,0 +1,392 @@ +from __future__ import annotations + +import re + +from ..core.logger import AppLogger +from ..database.orm import DepartmentORM, PositionORM +from ..repositories import OrganizationRepository + +logger = AppLogger.get_logger(__name__) + +DEFAULT_ORGANIZATION = [ + { + "code": "ORG001", + "name": "总经办", + "parent": None, + "positions": ["总经理", "副总经理", "总经理助理"], + }, + { + "code": "ORG002", + "name": "行政人事部", + "parent": None, + "positions": ["人事经理", "人事专员"], + }, + { + "code": "ORG003", + "name": "财务部", + "parent": None, + "positions": ["财务", "出纳专员"], + }, + { + "code": "ORG004", + "name": "订单部", + "parent": None, + "positions": ["销售助理", "订单助理"], + }, + { + "code": "ORG005", + "name": "工程部", + "parent": None, + "positions": [], + "remark": "系统根据工程部子部门自动补齐的父级部门", + }, + { + "code": "ORG006", + "name": "工程部-安装队", + "parent": "工程部", + "positions": ["经理助理", "安装师傅", "项目经理"], + }, + { + "code": "ORG007", + "name": "工程部-项目预算", + "parent": "工程部", + "positions": ["项目预算"], + }, + { + "code": "ORG008", + "name": "杭州运营中心", + "parent": None, + "positions": [], + "remark": "系统根据杭州运营中心子部门自动补齐的父级部门", + }, + { + "code": "ORG009", + "name": "杭州运营中心-销售部", + "parent": "杭州运营中心", + "positions": ["销售总监", "销售"], + }, + { + "code": "ORG010", + "name": "华南运营中心", + "parent": None, + "positions": ["销售总监"], + }, + { + "code": "ORG011", + "name": "华中运营中心", + "parent": None, + "positions": ["销售总监"], + }, + { + "code": "ORG012", + "name": "生产中心", + "parent": None, + "positions": ["机械工程师"], + }, + { + "code": "ORG013", + "name": "生产中心-采购部", + "parent": "生产中心", + "positions": ["采购"], + }, + { + "code": "ORG014", + "name": "生产中心-仓库", + "parent": "生产中心", + "positions": ["仓库", "仓库物料员"], + }, + { + "code": "ORG015", + "name": "生产中心-品管部", + "parent": "生产中心", + "positions": ["品管"], + }, + { + "code": "ORG016", + "name": "生产中心-生产部", + "parent": "生产中心", + "positions": ["焊接师傅", "普工", "技工"], + }, + { + "code": "ORG017", + "name": "西南运营中心", + "parent": None, + "positions": ["销售总监"], + }, + { + "code": "ORG018", + "name": "西南运营中心-办事处", + "parent": "西南运营中心", + "positions": ["经理助理"], + }, + { + "code": "ORG019", + "name": "西南运营中心-销售部", + "parent": "西南运营中心", + "positions": ["销售工程师"], + }, +] +DEFAULT_DEPARTMENTS = [item["name"] for item in DEFAULT_ORGANIZATION] + + +class OrganizationService: + """组织架构服务:维护部门、岗位和二者的上下级关系。""" + + def __init__(self, repository: OrganizationRepository): + self.repository = repository + + def ensure_default_departments(self) -> int: + """启动时补齐默认组织架构,包含部门树和部门岗位。""" + department_created = 0 + department_updated = 0 + position_created = 0 + position_updated = 0 + seeded_departments: dict[str, DepartmentORM] = {} + + for index, item in enumerate(DEFAULT_ORGANIZATION, start=1): + parent_name = item["parent"] + parent = seeded_departments.get(parent_name) if parent_name else None + if parent is None and parent_name: + parent = self.repository.get_department_by_name(parent_name) + existing = self.repository.get_department_by_name(item["name"]) + department_code = self._usable_department_code(str(item["code"]), existing) + payload = { + "parent_id": parent.id if parent else None, + "department_code": department_code, + "name": item["name"], + "sort_order": index, + "is_active": True, + "remark": item.get("remark", "系统初始化部门"), + } + if existing is None: + department = self.repository.create_department(**payload) + department_created += 1 + elif _department_changed(existing, payload): + department = self.repository.update_department(existing.id, **payload) + department_updated += 1 + else: + department = existing + seeded_departments[department.name] = department + + position_index = 1 + for item in DEFAULT_ORGANIZATION: + department = seeded_departments[item["name"]] + for position_sort, position_name in enumerate(item["positions"], start=1): + preferred_code = f"POS{position_index:03d}" + existing_position = self.repository.get_position_by_department_and_name( + department.id, + position_name, + ) + payload = { + "department_id": department.id, + "position_code": self._usable_position_code(preferred_code, existing_position), + "name": position_name, + "sort_order": position_sort, + "is_active": True, + "remark": "系统初始化岗位", + } + if existing_position is None: + self.repository.create_position(**payload) + position_created += 1 + elif _position_changed(existing_position, payload): + self.repository.update_position(existing_position.id, **payload) + position_updated += 1 + position_index += 1 + + logger.info( + "默认组织架构初始化完成 department_created=%s department_updated=%s position_created=%s position_updated=%s", + department_created, + department_updated, + position_created, + position_updated, + ) + return department_created + position_created + + def list_departments(self, *, active_only: bool = False, keyword: str | None = None) -> list[DepartmentORM]: + departments = self.repository.list_departments(active_only=active_only, keyword=_clean_optional(keyword)) + logger.info("查询部门成功 count=%s active_only=%s", len(departments), active_only) + return departments + + def create_department(self, **values) -> DepartmentORM: + payload = self._validated_department_values(values) + if self.repository.get_department_by_code(payload["department_code"]) is not None: + raise ValueError("部门编码已存在") + if self.repository.get_department_by_name(payload["name"]) is not None: + raise ValueError("部门名称已存在") + department = self.repository.create_department(**payload) + logger.info("新增部门成功 department_id=%s name=%s", department.id, department.name) + return department + + def update_department(self, department_id: int, **values) -> DepartmentORM: + payload = self._validated_department_values(values, department_id=department_id) + by_code = self.repository.get_department_by_code(payload["department_code"]) + if by_code is not None and by_code.id != department_id: + raise ValueError("部门编码已存在") + by_name = self.repository.get_department_by_name(payload["name"]) + if by_name is not None and by_name.id != department_id: + raise ValueError("部门名称已存在") + department = self.repository.update_department(department_id, **payload) + logger.info("更新部门成功 department_id=%s name=%s", department.id, department.name) + return department + + def list_positions( + self, + *, + department_id: int | None = None, + active_only: bool = False, + keyword: str | None = None, + ) -> list[PositionORM]: + if department_id: + self.repository.require_department(department_id) + positions = self.repository.list_positions( + department_id=department_id, + active_only=active_only, + keyword=_clean_optional(keyword), + ) + logger.info("查询岗位成功 count=%s department_id=%s", len(positions), department_id) + return positions + + def create_position(self, **values) -> PositionORM: + payload = self._validated_position_values(values) + if self.repository.get_position_by_code(payload["position_code"]) is not None: + raise ValueError("岗位编码已存在") + self._ensure_position_name_available(payload["department_id"], payload["name"]) + position = self.repository.create_position(**payload) + logger.info("新增岗位成功 position_id=%s name=%s", position.id, position.name) + return position + + def update_position(self, position_id: int, **values) -> PositionORM: + current = self.repository.require_position(position_id) + payload = self._validated_position_values(values, current=current) + by_code = self.repository.get_position_by_code(payload["position_code"]) + if by_code is not None and by_code.id != position_id: + raise ValueError("岗位编码已存在") + self._ensure_position_name_available(payload["department_id"], payload["name"], position_id=position_id) + position = self.repository.update_position(position_id, **payload) + logger.info("更新岗位成功 position_id=%s name=%s", position.id, position.name) + return position + + def _validated_department_values(self, values: dict, department_id: int | None = None) -> dict: + parent_id = values.get("parent_id") + parent_id = int(parent_id) if parent_id not in (None, "", 0) else None + if parent_id: + if parent_id == department_id: + raise ValueError("父级部门不能选择自己") + self.repository.require_department(parent_id) + department_code = str(values.get("department_code") or "").strip() + name = str(values.get("name") or "").strip() + if not department_code: + raise ValueError("部门编码不能为空") + if not name: + raise ValueError("部门名称不能为空") + return { + "parent_id": parent_id, + "department_code": department_code, + "name": name, + "sort_order": int(values.get("sort_order") or 0), + "is_active": bool(values.get("is_active", True)), + "remark": str(values.get("remark") or "").strip(), + } + + def _validated_position_values(self, values: dict, current: PositionORM | None = None) -> dict: + department_id = int(values.get("department_id") or 0) + self.repository.require_department(department_id) + position_code = str(values.get("position_code") or "").strip() + name = str(values.get("name") or "").strip() + if not position_code: + position_code = current.position_code if current else self._next_position_code() + if not name: + raise ValueError("岗位名称不能为空") + return { + "department_id": department_id, + "position_code": position_code, + "name": name, + "sort_order": int(values.get("sort_order") or 0), + "is_active": bool(values.get("is_active", True)), + "remark": str(values.get("remark") or "").strip(), + } + + def _ensure_position_name_available( + self, + department_id: int, + name: str, + *, + position_id: int | None = None, + ) -> None: + by_name = self.repository.get_position_by_department_and_name(department_id, name) + if by_name is not None and by_name.id != position_id: + raise ValueError("该部门下岗位名称已存在") + + def _usable_department_code(self, preferred_code: str, existing: DepartmentORM | None = None) -> str: + owner = self.repository.get_department_by_code(preferred_code) + if owner is None or (existing is not None and owner.id == existing.id): + return preferred_code + if existing is not None: + return existing.department_code + return self._next_department_code() + + def _usable_position_code(self, preferred_code: str, existing: PositionORM | None = None) -> str: + owner = self.repository.get_position_by_code(preferred_code) + if owner is None or (existing is not None and owner.id == existing.id): + return preferred_code + if existing is not None: + return existing.position_code + return self._next_position_code() + + def _next_department_code(self) -> str: + return _next_code( + [department.department_code for department in self.repository.list_departments(active_only=False)], + "ORG", + ) + + def _next_position_code(self) -> str: + return _next_code( + [position.position_code for position in self.repository.list_positions(active_only=False)], + "POS", + ) + + +def _clean_optional(value: str | None) -> str | None: + if value is None: + return None + stripped = value.strip() + return stripped or None + + +def _next_code(existing_codes: list[str], prefix: str) -> str: + pattern = re.compile(rf"^{re.escape(prefix)}(\d+)$") + used_numbers = { + int(match.group(1)) + for code in existing_codes + if (match := pattern.match(code)) + } + number = 1 + while number in used_numbers: + number += 1 + return f"{prefix}{number:03d}" + + +def _department_changed(department: DepartmentORM, payload: dict) -> bool: + return any( + [ + department.parent_id != payload["parent_id"], + department.department_code != payload["department_code"], + department.name != payload["name"], + department.sort_order != payload["sort_order"], + department.is_active != payload["is_active"], + department.remark != payload["remark"], + ] + ) + + +def _position_changed(position: PositionORM, payload: dict) -> bool: + return any( + [ + position.department_id != payload["department_id"], + position.position_code != payload["position_code"], + position.name != payload["name"], + position.sort_order != payload["sort_order"], + position.is_active != payload["is_active"], + position.remark != payload["remark"], + ] + ) diff --git a/financial_system/services/payroll_service.py b/financial_system/services/payroll_service.py new file mode 100644 index 0000000..fc75ad3 --- /dev/null +++ b/financial_system/services/payroll_service.py @@ -0,0 +1,238 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, datetime, time +from pathlib import Path +from typing import BinaryIO + +from ..core.config import AppConfig, PayrollRules, load_config +from ..core.logger import AppLogger +from ..core.settings import AppSettings +from ..domain.calculator import PayrollCalculator +from ..domain.models import EmployeeAttendance, EmployeePayConfig, PayrollResult +from ..integrations.dingtalk import DingTalkAttendanceAdapter, DingTalkClient, DingTalkSettings +from ..io.exporter import export_payroll_results +from ..io.parser import MonthlySummaryParser +from ..io.storage import FileStorage +from ..repositories import CommissionRepository, PayrollRepository, SalaryProfileRepository +from .salary_config_service import SalaryConfigService + +logger = AppLogger.get_logger(__name__) + + +class PayrollService: + """纯工资服务:只负责把考勤输入转换成工资计算结果。""" + + def __init__(self, config: AppConfig): + self.config = config + self.calculator = PayrollCalculator(config) + + def calculate_from_excel(self, excel_path: str | Path) -> list[PayrollResult]: + logger.info("Excel 工资计算开始 excel_path=%s", excel_path) + parser = MonthlySummaryParser(self.config.attendance) + employees = parser.parse(excel_path) + results = self.calculator.calculate(employees) + logger.info("Excel 工资计算完成 excel_path=%s employee_count=%s", excel_path, len(results)) + return results + + +@dataclass(frozen=True) +class PayrollComputation: + """一次工资计算的返回值,供 API schema 和持久化流程复用。""" + + job_id: str + results: list[PayrollResult] + output_file: str | None = None + + @property + def employee_count(self) -> int: + return len(self.results) + + @property + def output_filename(self) -> str | None: + return Path(self.output_file).name if self.output_file else None + + +class PayrollApplicationService: + """应用服务层:编排上传、外部接口、计算、导出和落库。""" + + def __init__( + self, + repository: PayrollRepository, + settings: AppSettings, + salary_repository: SalaryProfileRepository | None = None, + commission_repository: CommissionRepository | None = None, + config_service: SalaryConfigService | None = None, + ): + self.repository = repository + self.settings = settings + self.salary_repository = salary_repository + self.commission_repository = commission_repository + self.config_service = config_service + self.storage = FileStorage(settings) + + def calculate_from_excel_upload( + self, + filename: str, + stream: BinaryIO, + config_path: str | None = None, + export_excel: bool = True, + ) -> PayrollComputation: + logger.info("Excel 上传计算请求开始 filename=%s export_excel=%s", filename, export_excel) + upload_path = self.storage.save_upload(filename, stream) + job = self.repository.create_job("excel", input_file=str(upload_path)) + logger.info("Excel 工资计算任务已创建 job_id=%s upload_path=%s", job.id, upload_path) + try: + config = self._load_runtime_config(config_path) + employees = MonthlySummaryParser(config.attendance).parse(upload_path) + salary_month = self._salary_month_from_employees(employees) + config = self._load_runtime_config(config_path, salary_month=salary_month) + results = PayrollCalculator(config).calculate(employees) + output_file = self._export_if_needed(config, results, "excel", export_excel) + self.repository.save_results(job.id, results) + self.repository.mark_completed(job.id, len(results), output_file) + logger.info( + "Excel 工资计算任务完成 job_id=%s employee_count=%s output_file=%s", + job.id, + len(results), + output_file, + ) + return PayrollComputation(job_id=job.id, results=results, output_file=output_file) + except Exception as exc: + self.repository.mark_failed(job.id, str(exc)) + logger.exception("Excel 工资计算任务失败 job_id=%s upload_path=%s", job.id, upload_path) + raise + + async def calculate_from_dingtalk_realtime( + self, + user_ids: list[str], + start_date: date, + end_date: date, + config_path: str | None = None, + export_excel: bool = True, + ) -> PayrollComputation: + logger.info( + "钉钉实时工资计算请求开始 user_count=%s start_date=%s end_date=%s export_excel=%s", + len(user_ids), + start_date, + end_date, + export_excel, + ) + config = self._load_runtime_config(config_path, salary_month=start_date.strftime("%Y-%m")) + settings = self._dingtalk_settings() + job = self.repository.create_job("dingtalk") + logger.info("钉钉工资计算任务已创建 job_id=%s", job.id) + try: + client = DingTalkClient(settings) + start_dt = datetime.combine(start_date, time.min) + end_dt = datetime.combine(end_date, time(23, 59, 59)) + records = await client.list_attendance_records(user_ids, start_dt, end_dt) + adapter = DingTalkAttendanceAdapter(config.attendance, timezone=settings.timezone) + employees = adapter.to_employee_attendance(records) + results = PayrollCalculator(config).calculate(employees) + output_file = self._export_if_needed(config, results, "dingtalk", export_excel) + self.repository.save_results(job.id, results) + self.repository.mark_completed(job.id, len(results), output_file) + logger.info( + "钉钉工资计算任务完成 job_id=%s record_count=%s employee_count=%s output_file=%s", + job.id, + len(records), + len(results), + output_file, + ) + return PayrollComputation(job_id=job.id, results=results, output_file=output_file) + except Exception as exc: + self.repository.mark_failed(job.id, str(exc)) + logger.exception("钉钉工资计算任务失败 job_id=%s", job.id) + raise + + def get_job(self, job_id: str): + job = self.repository.get_job(job_id) + logger.info("查询工资计算任务 job_id=%s found=%s", job_id, job is not None) + return job + + def resolve_output_file(self, filename: str) -> Path: + logger.info("解析工资结果下载文件 filename=%s", filename) + return self.storage.resolve_output_file(filename) + + def _export_if_needed( + self, + config: AppConfig, + results: list[PayrollResult], + source_type: str, + export_excel: bool, + ) -> str | None: + if not export_excel: + logger.info("跳过工资结果导出 source_type=%s", source_type) + return None + output_path = self.storage.payroll_output_path(source_type) + export_payroll_results(results, config, output_path) + logger.info("工资结果导出完成 source_type=%s output_path=%s", source_type, output_path) + return str(output_path) + + def _load_runtime_config(self, config_path: str | None, salary_month: str | None = None) -> AppConfig: + config = load_config(config_path) + if self.config_service is not None: + self.config_service.ensure_defaults() + config = self.config_service.resolve_app_config(config) + if self.salary_repository is None: + return config + + pay_configs = dict(config.payroll.employees) + profiles = self.salary_repository.list_active_profiles() + month_commissions = ( + self.commission_repository.aggregate_by_month(salary_month) + if self.commission_repository is not None and salary_month + else {} + ) + for profile in profiles: + employee = profile.employee + if not employee.name: + continue + pay_configs[employee.name] = EmployeePayConfig( + salary_mode=profile.salary_mode, + base_salary=profile.base_salary, + hourly_rate=profile.hourly_rate, + piece_quantity=profile.piece_quantity, + piece_unit_price=profile.piece_unit_price, + probation_type=profile.probation_type, + probation_ratio=profile.probation_ratio, + probation_salary=profile.probation_salary or None, + commission_amount=profile.commission_amount + month_commissions.get(employee.id, 0), + overtime_rate=profile.overtime_rate, + weekend_overtime_rate=profile.weekend_overtime_rate, + holiday_overtime_rate=profile.holiday_overtime_rate, + ) + if profiles: + logger.info( + "已合并数据库薪资档案到工资计算配置 count=%s salary_month=%s commission_employee_count=%s", + len(profiles), + salary_month, + len(month_commissions), + ) + return AppConfig( + attendance=config.attendance, + payroll=PayrollRules( + default_overtime_rate=config.payroll.default_overtime_rate, + default_weekend_overtime_rate=config.payroll.default_weekend_overtime_rate, + default_holiday_overtime_rate=config.payroll.default_holiday_overtime_rate, + employees=pay_configs, + ), + ) + + def _salary_month_from_employees(self, employees: list[EmployeeAttendance]) -> str | None: + for employee in employees: + if employee.daily_records: + return employee.daily_records[0].work_date.strftime("%Y-%m") + return None + + def _dingtalk_settings(self) -> DingTalkSettings: + if not self.settings.dingtalk_app_key or not self.settings.dingtalk_app_secret: + logger.error("钉钉配置缺失 config_path=%s", self.settings.config_path) + raise ValueError("请先在 config/app_settings.json 中配置 dingtalk.app_key 和 dingtalk.app_secret") + return DingTalkSettings( + app_key=self.settings.dingtalk_app_key, + app_secret=self.settings.dingtalk_app_secret, + base_url=self.settings.dingtalk_base_url, + timezone=self.settings.dingtalk_timezone, + ) diff --git a/financial_system/services/report_service.py b/financial_system/services/report_service.py new file mode 100644 index 0000000..038d2cb --- /dev/null +++ b/financial_system/services/report_service.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass + +from ..core.logger import AppLogger +from ..database.orm import SalaryRecordORM +from ..repositories import ReportRepository + +logger = AppLogger.get_logger(__name__) +MONTH_RE = re.compile(r"^\d{4}-\d{2}$") + + +@dataclass(frozen=True) +class PayrollReport: + salary_month: str | None + totals: dict[str, float] + attendance: dict[str, float] + departments: list[dict[str, float | str]] + recent_records: list[SalaryRecordORM] + + +class ReportService: + """报表服务:提供薪资、考勤和部门维度的聚合结果。""" + + def __init__(self, repository: ReportRepository): + self.repository = repository + + def payroll_summary(self, *, salary_month: str | None = None) -> PayrollReport: + month = _clean_optional(salary_month) + if month and not MONTH_RE.match(month): + raise ValueError("工资月份格式必须为YYYY-MM") + report = PayrollReport( + salary_month=month, + totals=self.repository.payroll_totals(salary_month=month), + attendance=self.repository.attendance_totals(salary_month=month), + departments=self.repository.department_totals(salary_month=month), + recent_records=self.repository.recent_salary_records(salary_month=month), + ) + logger.info("查询工资统计报表成功 salary_month=%s employee_count=%s", month, report.totals["employee_count"]) + return report + + +def _clean_optional(value: str | None) -> str | None: + if value is None: + return None + stripped = value.strip() + return stripped or None diff --git a/financial_system/services/salary_config_service.py b/financial_system/services/salary_config_service.py new file mode 100644 index 0000000..c2238b5 --- /dev/null +++ b/financial_system/services/salary_config_service.py @@ -0,0 +1,228 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass + +from ..core.config import AppConfig, AttendanceRules, PayrollRules +from ..core.logger import AppLogger +from ..database.orm import SalaryConfigORM +from ..repositories import SalaryConfigRepository + +logger = AppLogger.get_logger(__name__) + + +DEFAULT_CONFIGS = [ + { + "config_key": "attendance.on_work_time", + "config_name": "上班时间", + "config_group": "attendance", + "config_value": "08:30", + "value_type": "string", + "remark": "用于迟到识别,格式HH:MM", + }, + { + "config_key": "attendance.off_work_time", + "config_name": "下班时间", + "config_group": "attendance", + "config_value": "17:00", + "value_type": "string", + "remark": "加班按下班打卡超过该时间后向下取整", + }, + { + "config_key": "attendance.overtime_round_minutes", + "config_name": "加班取整分钟", + "config_group": "attendance", + "config_value": "60", + "value_type": "integer", + "remark": "例如60表示20:30按3小时计算", + }, + { + "config_key": "attendance.standard_daily_hours", + "config_name": "标准日工时", + "config_group": "attendance", + "config_value": "8", + "value_type": "number", + "remark": "计时工资按出勤天数折算工时", + }, + { + "config_key": "attendance.minor_late_minutes", + "config_name": "轻微迟到分钟", + "config_group": "attendance", + "config_value": "5", + "value_type": "integer", + "remark": "小于等于该分钟数按轻微迟到统计", + }, + { + "config_key": "attendance.minor_late_free_times", + "config_name": "轻微迟到免扣次数", + "config_group": "attendance", + "config_value": "3", + "value_type": "integer", + "remark": "5分钟内迟到3次内合格,超过后按次扣款", + }, + { + "config_key": "attendance.late_penalty", + "config_name": "迟到扣款", + "config_group": "attendance", + "config_value": "30", + "value_type": "number", + "remark": "每次计扣迟到扣款金额", + }, + { + "config_key": "attendance.missing_card_penalty", + "config_name": "缺卡扣款", + "config_group": "attendance", + "config_value": "20", + "value_type": "number", + "remark": "每次缺卡扣款金额", + }, + { + "config_key": "attendance.weekend_days", + "config_name": "周末星期", + "config_group": "attendance", + "config_value": "[5, 6]", + "value_type": "json", + "remark": "Python weekday:周一0,周六5,周日6", + }, + { + "config_key": "attendance.legal_holidays", + "config_name": "法定节假日", + "config_group": "attendance", + "config_value": "[]", + "value_type": "json", + "remark": "日期数组,例如[\"2026-10-01\"]", + }, + { + "config_key": "attendance.leave_keywords", + "config_name": "请假关键字", + "config_group": "attendance", + "config_value": json.dumps(list(AttendanceRules.leave_keywords), ensure_ascii=False), + "value_type": "json", + "remark": "从钉钉单元格识别请假/调休类型", + }, + { + "config_key": "payroll.default_overtime_rate", + "config_name": "工作日加班单价", + "config_group": "payroll", + "config_value": "0", + "value_type": "number", + "remark": "未维护员工单价时使用", + }, + { + "config_key": "payroll.default_weekend_overtime_rate", + "config_name": "周末加班单价", + "config_group": "payroll", + "config_value": "0", + "value_type": "number", + "remark": "未维护员工周末单价时使用", + }, + { + "config_key": "payroll.default_holiday_overtime_rate", + "config_name": "节假日加班单价", + "config_group": "payroll", + "config_value": "0", + "value_type": "number", + "remark": "未维护员工节假日单价时使用", + }, +] + + +@dataclass(frozen=True) +class SalaryConfigUpdate: + config_name: str + config_group: str + config_value: str + value_type: str + is_enabled: bool + remark: str + + +class SalaryConfigService: + """系统配置服务:维护规则配置,并把数据库配置合成为 AppConfig。""" + + def __init__(self, repository: SalaryConfigRepository): + self.repository = repository + + def ensure_defaults(self) -> int: + created = self.repository.create_defaults(DEFAULT_CONFIGS) + logger.info("系统默认配置初始化完成 created=%s", created) + return created + + def list_configs(self, *, config_group: str | None = None) -> list[SalaryConfigORM]: + configs = self.repository.list_configs(config_group=_clean_optional(config_group)) + logger.info("查询系统配置成功 count=%s group=%s", len(configs), config_group) + return configs + + def update_config(self, config_key: str, values: SalaryConfigUpdate) -> SalaryConfigORM: + self._parse_value(values.config_value, values.value_type) + config = self.repository.upsert_config( + config_key=config_key, + config_name=values.config_name.strip(), + config_group=values.config_group.strip(), + config_value=values.config_value.strip(), + value_type=values.value_type.strip(), + is_enabled=values.is_enabled, + remark=values.remark.strip(), + ) + logger.info("更新系统配置成功 config_key=%s", config_key) + return config + + def resolve_app_config(self, base_config: AppConfig) -> AppConfig: + values = { + config.config_key: self._parse_value(config.config_value, config.value_type) + for config in self.repository.list_configs() + if config.is_enabled + } + attendance = AttendanceRules( + on_work_time=str(values.get("attendance.on_work_time", base_config.attendance.on_work_time)), + off_work_time=str(values.get("attendance.off_work_time", base_config.attendance.off_work_time)), + overtime_round_minutes=int( + values.get("attendance.overtime_round_minutes", base_config.attendance.overtime_round_minutes) + ), + standard_daily_hours=float( + values.get("attendance.standard_daily_hours", base_config.attendance.standard_daily_hours) + ), + minor_late_minutes=int(values.get("attendance.minor_late_minutes", base_config.attendance.minor_late_minutes)), + minor_late_free_times=int( + values.get("attendance.minor_late_free_times", base_config.attendance.minor_late_free_times) + ), + late_penalty=float(values.get("attendance.late_penalty", base_config.attendance.late_penalty)), + missing_card_penalty=float( + values.get("attendance.missing_card_penalty", base_config.attendance.missing_card_penalty) + ), + weekend_days=tuple(int(item) for item in values.get("attendance.weekend_days", base_config.attendance.weekend_days)), + legal_holidays=tuple(str(item) for item in values.get("attendance.legal_holidays", base_config.attendance.legal_holidays)), + leave_keywords=tuple(str(item) for item in values.get("attendance.leave_keywords", base_config.attendance.leave_keywords)), + ) + payroll = PayrollRules( + default_overtime_rate=float( + values.get("payroll.default_overtime_rate", base_config.payroll.default_overtime_rate) + ), + default_weekend_overtime_rate=float( + values.get("payroll.default_weekend_overtime_rate", base_config.payroll.default_weekend_overtime_rate) + ), + default_holiday_overtime_rate=float( + values.get("payroll.default_holiday_overtime_rate", base_config.payroll.default_holiday_overtime_rate) + ), + employees=base_config.payroll.employees, + ) + logger.info("已合并数据库规则配置到运行时计算配置") + return AppConfig(attendance=attendance, payroll=payroll) + + def _parse_value(self, raw_value: str, value_type: str): + if value_type == "integer": + return int(raw_value) + if value_type == "number": + return float(raw_value) + if value_type == "boolean": + return str(raw_value).lower() in {"1", "true", "yes", "on"} + if value_type == "json": + return json.loads(raw_value or "null") + return raw_value + + +def _clean_optional(value: str | None) -> str | None: + if value is None: + return None + stripped = value.strip() + return stripped or None diff --git a/financial_system/services/salary_profile_service.py b/financial_system/services/salary_profile_service.py new file mode 100644 index 0000000..3c9df95 --- /dev/null +++ b/financial_system/services/salary_profile_service.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +from ..core.logger import AppLogger +from ..database.orm import SalaryProfileORM +from ..domain.models import EmployeePayConfig +from ..repositories import EmployeeRepository, SalaryProfileRepository + +logger = AppLogger.get_logger(__name__) + + +VALID_SALARY_MODES = {"monthly", "hourly", "piecework", "probation"} +VALID_PROBATION_TYPES = {"ratio", "fixed"} + + +class SalaryProfileService: + """薪资档案应用服务:维护不同薪资模式和独立加班费单价。""" + + def __init__(self, repository: SalaryProfileRepository, employee_repository: EmployeeRepository): + self.repository = repository + self.employee_repository = employee_repository + + def upsert_profile( + self, + *, + employee_id: int, + salary_mode: str, + base_salary: float, + hourly_rate: float, + piece_quantity: float, + piece_unit_price: float, + probation_type: str, + probation_ratio: float, + probation_salary: float, + commission_amount: float, + overtime_rate: float, + weekend_overtime_rate: float, + holiday_overtime_rate: float, + effective_month: str = "", + remark: str = "", + ) -> SalaryProfileORM: + self.employee_repository.require_by_id(employee_id) + normalized_salary_mode = salary_mode.strip() or "monthly" + if normalized_salary_mode not in VALID_SALARY_MODES: + raise ValueError("薪资模式不合法") + normalized_probation_type = probation_type.strip() or "ratio" + if normalized_probation_type not in VALID_PROBATION_TYPES: + raise ValueError("试用期计算方式不合法") + profile = self.repository.upsert_profile( + employee_id=employee_id, + salary_mode=normalized_salary_mode, + base_salary=_non_negative(base_salary, "基础工资"), + hourly_rate=_non_negative(hourly_rate, "计时小时单价"), + piece_quantity=_non_negative(piece_quantity, "计件数量"), + piece_unit_price=_non_negative(piece_unit_price, "计件单价"), + probation_type=normalized_probation_type, + probation_ratio=_non_negative(probation_ratio, "试用期比例") or 1, + probation_salary=_non_negative(probation_salary, "试用期固定工资"), + commission_amount=_non_negative(commission_amount, "提成费用"), + overtime_rate=_non_negative(overtime_rate, "工作日加班费小时单价"), + weekend_overtime_rate=_non_negative(weekend_overtime_rate, "周末加班费小时单价"), + holiday_overtime_rate=_non_negative(holiday_overtime_rate, "节假日加班费小时单价"), + effective_month=effective_month.strip(), + remark=remark.strip(), + ) + logger.info( + "薪资档案保存成功 employee_id=%s salary_mode=%s base_salary=%s commission=%s overtime_rate=%s", + employee_id, + profile.salary_mode, + profile.base_salary, + profile.commission_amount, + profile.overtime_rate, + ) + return profile + + def list_profiles(self, *, keyword: str | None = None) -> list[SalaryProfileORM]: + profiles = self.repository.list_profiles(keyword=_clean_optional(keyword)) + logger.info("查询薪资档案成功 count=%s", len(profiles)) + return profiles + + def active_pay_config_by_employee_name(self) -> dict[str, EmployeePayConfig]: + """生成工资计算器使用的员工薪资配置,按员工姓名匹配考勤表。""" + profiles = self.repository.list_active_profiles() + result: dict[str, EmployeePayConfig] = {} + for profile in profiles: + employee = profile.employee + if not employee.name: + continue + result[employee.name] = EmployeePayConfig( + salary_mode=profile.salary_mode, + base_salary=profile.base_salary, + hourly_rate=profile.hourly_rate, + piece_quantity=profile.piece_quantity, + piece_unit_price=profile.piece_unit_price, + probation_type=profile.probation_type, + probation_ratio=profile.probation_ratio, + probation_salary=profile.probation_salary, + commission_amount=profile.commission_amount, + overtime_rate=profile.overtime_rate, + weekend_overtime_rate=profile.weekend_overtime_rate, + holiday_overtime_rate=profile.holiday_overtime_rate, + ) + logger.info("薪资档案转换为工资计算配置 count=%s", len(result)) + return result + + +def _non_negative(value: float, label: str) -> float: + number = float(value or 0) + if number < 0: + raise ValueError(f"{label}不能小于0") + return number + + +def _clean_optional(value: str | None) -> str | None: + if value is None: + return None + stripped = value.strip() + return stripped or None diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..f44cee9 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,2 @@ +VITE_API_BASE_URL= +VITE_DEV_PROXY_TARGET=http://127.0.0.1:8000 diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..07eacc6 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,141 @@ +# 企业薪酬管理系统前端 + +这是 `Financial_System` 项目的前端子工程,使用 Vue3、Vite、TypeScript、Pinia 和 Vue Router 开发,用于对接后端 FastAPI 工资计算服务。 + +## 技术栈 + +- Vue3:页面和组件开发。 +- Vite:本地开发服务和生产构建。 +- TypeScript:接口类型、状态类型和页面逻辑类型约束。 +- Pinia:登录用户、菜单和权限状态管理。 +- Vue Router:页面路由和权限守卫。 +- @lucide/vue:按钮、菜单和状态图标。 + +## 目录结构 + +| 目录 | 说明 | +| --- | --- | +| `src/api/` | 后端接口封装、请求工具、接口类型、文件下载逻辑。 | +| `src/stores/` | Pinia 状态管理,目前主要是登录态、菜单、权限和主题色。 | +| `src/router/` | 路由配置和登录/权限守卫。 | +| `src/components/` | 通用布局、侧边栏、顶部栏、统计卡片和工资结果表格。 | +| `src/views/` | 登录、工作台、工资计算、计算记录、提成管理、统计报表、组织架构、员工维护、薪资维护、规则配置、用户管理、操作日志页面。 | +| `src/assets/` | 全局样式和 UI 设计变量。 | + +## 本地启动 + +先启动后端服务: + +```bash +cd /Users/jiaolongyan/PycharmProjects/牛牛小屋/Financial_System +/Users/jiaolongyan/miniconda3/envs/Financial_System/bin/python main.py +``` + +再启动前端服务: + +```bash +cd /Users/jiaolongyan/PycharmProjects/牛牛小屋/Financial_System/frontend +npm install +npm run dev +``` + +默认访问地址: + +```text +http://127.0.0.1:5173/ +``` + +## 后端接口代理 + +开发环境下,`vite.config.ts` 已将以下路径代理到后端: + +```text +/api -> http://127.0.0.1:8000 +/health -> http://127.0.0.1:8000 +/static -> http://127.0.0.1:8000 +``` + +因此前端代码中可以直接请求 `/api/auth/login`、`/api/payroll/excel` 等路径,头像静态资源也可以直接访问 `/static/uploads/avatars/...`。 + +如果部署时前端和后端不在同一个域名,可以复制 `.env.example` 为 `.env`,并设置: + +```env +VITE_API_BASE_URL=http://127.0.0.1:8000 +VITE_DEV_PROXY_TARGET=http://127.0.0.1:8000 +``` + +如果后端 `8000` 端口被旧进程占用,新后端自动启动到了 `8001`,需要把开发代理改成: + +```env +VITE_DEV_PROXY_TARGET=http://127.0.0.1:8001 +``` + +修改 `.env` 后需要重启前端开发服务。 + +## 默认账号 + +后端首次启动会初始化超级管理员: + +```text +用户名:admin +密码:Admin@123456 +角色:superuser +``` + +登录成功后,前端会根据后端返回的 `menus` 渲染左侧菜单,根据 `permissions` 控制按钮和操作权限。 + +## 页面说明 + +| 页面 | 路由 | 说明 | +| --- | --- | --- | +| 登录 | `/login` | 用户登录入口。 | +| 工作台 | `/dashboard` | 展示最近计算任务、统计卡片和趋势面板。 | +| 工资计算 | `/payroll` | 支持 Excel 导入和钉钉实时计算。 | +| 计算记录 | `/payroll/jobs` | 支持按任务编号查询已落库工资结果。 | +| 提成管理 | `/payroll/commissions` | 支持手工维护提成和 Excel 批量导入。 | +| 统计报表 | `/reports` | 查看工资汇总、部门工资成本和考勤工时统计。 | +| 组织架构 | `/system/organization` | 维护部门上下级和部门下的岗位。 | +| 员工维护 | `/system/employees` | 维护员工编号、姓名、钉钉用户ID、部门、岗位和状态。 | +| 薪资维护 | `/system/salary-profiles` | 维护包月、计时、计件、试用期工资规则和三类加班费单价。 | +| 规则配置 | `/system/configs` | 维护考勤扣款、加班取整、请假关键字和默认加班单价。 | +| 用户管理 | `/system/users` | 超级用户创建账号、查看账号和角色权限。 | +| 操作日志 | `/system/operation-logs` | 支持按操作人、模块、动作、状态和关键字查看系统操作记录。 | + +组织架构页面中的岗位编码由后端自动生成,新增或编辑岗位时不需要手工输入;员工维护页面会按所选部门联动展示该部门下的岗位,员工编号由后端按 `ZA0001`、`ZA0002` 规则自动生成。 + +## 个性化与个人资料 + +顶部栏提供两个常用入口: + +- 调色盘按钮:切换主题色,支持深海蓝、专业蓝、翡翠绿、勃艮第,选择结果保存在浏览器本地。 +- 头像/姓名区域:打开个人资料弹窗,可修改显示名称、邮箱、手机号、部门、职位,并上传头像。 + +头像上传接口为 `/api/auth/me/avatar`,后端会保存到 `storage.avatar_dir`,默认是: + +```text +static/uploads/avatars +``` + +## 权限说明 + +| 角色 | 可见菜单和操作 | +| --- | --- | +| `superuser` | 拥有全部菜单和全部操作权限。 | +| `manager` | 可进行工资计算、组织架构、员工维护、薪资维护、提成管理、规则配置、报表查看和操作日志查看。 | +| `viewer` | 可查看计算记录、下载结果和统计报表。 | + +## 构建 + +```bash +cd /Users/jiaolongyan/PycharmProjects/牛牛小屋/Financial_System/frontend +npm run build +``` + +构建产物生成在 `dist/`,该目录已加入项目 `.gitignore`。 + +## 常见问题 + +1. 登录失败:确认后端已启动,并且 `/health` 返回 `{"status":"ok"}`。 +2. 前端请求 401:重新登录,或确认当前用户拥有对应权限。 +3. 下载结果失败:确认任务已生成 `output_file`,且后端 `outputs/` 中文件未被删除。 +4. 端口被占用:可临时执行 `npm run dev -- --port 5174` 更换端口。 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..ee58142 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + 企业薪酬管理系统 + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..835ddff --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1570 @@ +{ + "name": "financial-system-frontend", + "version": "0.1.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "financial-system-frontend", + "version": "0.1.0", + "dependencies": { + "@lucide/vue": "1.18.0", + "pinia": "2.1.7", + "vue": "3.4.15", + "vue-router": "4.2.5" + }, + "devDependencies": { + "@vitejs/plugin-vue": "4.6.2", + "typescript": "5.3.3", + "vite": "4.5.14", + "vue-tsc": "1.8.27" + }, + "engines": { + "node": ">=16.14.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" + }, + "node_modules/@lucide/vue": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@lucide/vue/-/vue-1.18.0.tgz", + "integrity": "sha512-DmnUpDB85PlMZ+ofjZLcKq3JoJnaD1bk7SIj9xwUvqerfNqA6hCLa0/m3gIybH6rdrErABbqvTD8yYJdNqiZ3Q==", + "peerDependencies": { + "vue": ">=3.0.1" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-4.6.2.tgz", + "integrity": "sha512-kqf7SGFoG+80aZG6Pf+gsZIVvGSCKE98JbiWqcCV9cThtg91Jav0yvYFC9Zb+jKetNGF6ZKeoaxgZfND21fWKw==", + "dev": true, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.0.0 || ^5.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-1.11.1.tgz", + "integrity": "sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw==", + "dev": true, + "dependencies": { + "@volar/source-map": "1.11.1" + } + }, + "node_modules/@volar/source-map": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-1.11.1.tgz", + "integrity": "sha512-hJnOnwZ4+WT5iupLRnuzbULZ42L7BWWPMmruzwtLhJfpDVoZLjNBxHDi2sY2bgZXCKlpU5XcsMFoYrsQmPhfZg==", + "dev": true, + "dependencies": { + "muggle-string": "^0.3.1" + } + }, + "node_modules/@volar/typescript": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-1.11.1.tgz", + "integrity": "sha512-iU+t2mas/4lYierSnoFOeRFQUhAEMgsFuQxoxvwn5EdQopw43j+J27a4lt9LMInx1gLJBC6qL14WYGlgymaSMQ==", + "dev": true, + "dependencies": { + "@volar/language-core": "1.11.1", + "path-browserify": "^1.0.1" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.4.15", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.15.tgz", + "integrity": "sha512-XcJQVOaxTKCnth1vCxEChteGuwG6wqnUHxAm1DO3gCz0+uXKaJNx8/digSz4dLALCy8n2lKq24jSUs8segoqIw==", + "dependencies": { + "@babel/parser": "^7.23.6", + "@vue/shared": "3.4.15", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.0.2" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.4.15", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.15.tgz", + "integrity": "sha512-wox0aasVV74zoXyblarOM3AZQz/Z+OunYcIHe1OsGclCHt8RsRm04DObjefaI82u6XDzv+qGWZ24tIsRAIi5MQ==", + "dependencies": { + "@vue/compiler-core": "3.4.15", + "@vue/shared": "3.4.15" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.4.15", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.15.tgz", + "integrity": "sha512-LCn5M6QpkpFsh3GQvs2mJUOAlBQcCco8D60Bcqmf3O3w5a+KWS5GvYbrrJBkgvL1BDnTp+e8q0lXCLgHhKguBA==", + "dependencies": { + "@babel/parser": "^7.23.6", + "@vue/compiler-core": "3.4.15", + "@vue/compiler-dom": "3.4.15", + "@vue/compiler-ssr": "3.4.15", + "@vue/shared": "3.4.15", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.5", + "postcss": "^8.4.33", + "source-map-js": "^1.0.2" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.4.15", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.15.tgz", + "integrity": "sha512-1jdeQyiGznr8gjFDadVmOJqZiLNSsMa5ZgqavkPZ8O2wjHv0tVuAEsw5hTdUoUW4232vpBbL/wJhzVW/JwY1Uw==", + "dependencies": { + "@vue/compiler-dom": "3.4.15", + "@vue/shared": "3.4.15" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==" + }, + "node_modules/@vue/language-core": { + "version": "1.8.27", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-1.8.27.tgz", + "integrity": "sha512-L8Kc27VdQserNaCUNiSFdDl9LWT24ly8Hpwf1ECy3aFb9m6bDhBGQYOujDm21N7EW3moKIOKEanQwe1q5BK+mA==", + "dev": true, + "dependencies": { + "@volar/language-core": "~1.11.1", + "@volar/source-map": "~1.11.1", + "@vue/compiler-dom": "^3.3.0", + "@vue/shared": "^3.3.0", + "computeds": "^0.0.1", + "minimatch": "^9.0.3", + "muggle-string": "^0.3.1", + "path-browserify": "^1.0.1", + "vue-template-compiler": "^2.7.14" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.4.15", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.15.tgz", + "integrity": "sha512-55yJh2bsff20K5O84MxSvXKPHHt17I2EomHznvFiJCAZpJTNW8IuLj1xZWMLELRhBK3kkFV/1ErZGHJfah7i7w==", + "dependencies": { + "@vue/shared": "3.4.15" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.4.15", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.15.tgz", + "integrity": "sha512-6E3by5m6v1AkW0McCeAyhHTw+3y17YCOKG0U0HDKDscV4Hs0kgNT5G+GCHak16jKgcCDHpI9xe5NKb8sdLCLdw==", + "dependencies": { + "@vue/reactivity": "3.4.15", + "@vue/shared": "3.4.15" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.4.15", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.15.tgz", + "integrity": "sha512-EVW8D6vfFVq3V/yDKNPBFkZKGMFSvZrUQmx196o/v2tHKdwWdiZjYUBS+0Ez3+ohRyF8Njwy/6FH5gYJ75liUw==", + "dependencies": { + "@vue/runtime-core": "3.4.15", + "@vue/shared": "3.4.15", + "csstype": "^3.1.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.4.15", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.15.tgz", + "integrity": "sha512-3HYzaidu9cHjrT+qGUuDhFYvF/j643bHC6uUN9BgM11DVy+pM6ATsG6uPBLnkwOgs7BpJABReLmpL3ZPAsUaqw==", + "dependencies": { + "@vue/compiler-ssr": "3.4.15", + "@vue/shared": "3.4.15" + }, + "peerDependencies": { + "vue": "3.4.15" + } + }, + "node_modules/@vue/shared": { + "version": "3.4.15", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.15.tgz", + "integrity": "sha512-KzfPTxVaWfB+eGcGdbSf4CWdaXcGDqckoeXUh7SB3fZdEtzPCK2Vq9B/lRRL3yutax/LWITz+SwvgyOxz5V75g==" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/computeds": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/computeds/-/computeds-0.0.1.tgz", + "integrity": "sha512-7CEBgcMjVmitjYo5q8JTJVra6X5mQ20uTThdK+0kR7UEaDrAWEQcRiBtWJzga4eRpP6afNwwLsX2SET2JhVB1Q==", + "dev": true + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "bin": { + "he": "bin/he" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/muggle-string": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.3.1.tgz", + "integrity": "sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/pinia": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.1.7.tgz", + "integrity": "sha512-+C2AHFtcFqjPih0zpYuvof37SFxMQ7OEG2zV9jRI12i9BOy3YQVAHwdKtyyc8pDcDyIc33WCIsZaCFWU7WWxGQ==", + "dependencies": { + "@vue/devtools-api": "^6.5.0", + "vue-demi": ">=0.14.5" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "@vue/composition-api": "^1.4.0", + "typescript": ">=4.4.4", + "vue": "^2.6.14 || ^3.3.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/pinia/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.30.0.tgz", + "integrity": "sha512-kQvGasUgN+AlWGliFn2POSajRQEsULVYFGTvOZmK06d7vCD+YhZztt70kGk3qaeAXeWYL5eO7zx+rAubBc55eA==", + "dev": true, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=14.18.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", + "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", + "devOptional": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "4.5.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.14.tgz", + "integrity": "sha512-+v57oAaoYNnO3hIu5Z/tJRZjq5aHM2zDve9YZ8HngVHbhk66RStobhb1sqPMIPEleV6cNKYK4eGrAbE9Ulbl2g==", + "dev": true, + "dependencies": { + "esbuild": "^0.18.10", + "postcss": "^8.4.27", + "rollup": "^3.27.1" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@types/node": ">= 14", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.4.15", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.4.15.tgz", + "integrity": "sha512-jC0GH4KkWLWJOEQjOpkqU1bQsBwf4R1rsFtw5GQJbjHVKWDzO6P0nWWBTmjp1xSemAioDFj1jdaK1qa3DnMQoQ==", + "dependencies": { + "@vue/compiler-dom": "3.4.15", + "@vue/compiler-sfc": "3.4.15", + "@vue/runtime-dom": "3.4.15", + "@vue/server-renderer": "3.4.15", + "@vue/shared": "3.4.15" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.2.5.tgz", + "integrity": "sha512-DIUpKcyg4+PTQKfFPX88UWhlagBEBEfJ5A8XDXRJLUnZOvcpMF8o/dnL90vpVkGaPbjvXazV/rC1qBKrZlFugw==", + "dependencies": { + "@vue/devtools-api": "^6.5.0" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/vue-template-compiler": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.7.16.tgz", + "integrity": "sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==", + "dev": true, + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/vue-tsc": { + "version": "1.8.27", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-1.8.27.tgz", + "integrity": "sha512-WesKCAZCRAbmmhuGl3+VrdWItEvfoFIPXOvUJkjULi+x+6G/Dy69yO3TBRJDr9eUlmsNAwVmxsNZxvHKzbkKdg==", + "dev": true, + "dependencies": { + "@volar/typescript": "~1.11.1", + "@vue/language-core": "1.8.27", + "semver": "^7.5.4" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": "*" + } + } + }, + "dependencies": { + "@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==" + }, + "@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==" + }, + "@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "requires": { + "@babel/types": "^7.29.7" + } + }, + "@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "requires": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + } + }, + "@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "dev": true, + "optional": true + }, + "@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "dev": true, + "optional": true + }, + "@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "dev": true, + "optional": true + }, + "@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "dev": true, + "optional": true + }, + "@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "dev": true, + "optional": true + }, + "@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "dev": true, + "optional": true + }, + "@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "dev": true, + "optional": true + }, + "@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "dev": true, + "optional": true + }, + "@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "dev": true, + "optional": true + }, + "@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "dev": true, + "optional": true + }, + "@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "dev": true, + "optional": true + }, + "@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" + }, + "@lucide/vue": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@lucide/vue/-/vue-1.18.0.tgz", + "integrity": "sha512-DmnUpDB85PlMZ+ofjZLcKq3JoJnaD1bk7SIj9xwUvqerfNqA6hCLa0/m3gIybH6rdrErABbqvTD8yYJdNqiZ3Q==", + "requires": {} + }, + "@vitejs/plugin-vue": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-4.6.2.tgz", + "integrity": "sha512-kqf7SGFoG+80aZG6Pf+gsZIVvGSCKE98JbiWqcCV9cThtg91Jav0yvYFC9Zb+jKetNGF6ZKeoaxgZfND21fWKw==", + "dev": true, + "requires": {} + }, + "@volar/language-core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-1.11.1.tgz", + "integrity": "sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw==", + "dev": true, + "requires": { + "@volar/source-map": "1.11.1" + } + }, + "@volar/source-map": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-1.11.1.tgz", + "integrity": "sha512-hJnOnwZ4+WT5iupLRnuzbULZ42L7BWWPMmruzwtLhJfpDVoZLjNBxHDi2sY2bgZXCKlpU5XcsMFoYrsQmPhfZg==", + "dev": true, + "requires": { + "muggle-string": "^0.3.1" + } + }, + "@volar/typescript": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-1.11.1.tgz", + "integrity": "sha512-iU+t2mas/4lYierSnoFOeRFQUhAEMgsFuQxoxvwn5EdQopw43j+J27a4lt9LMInx1gLJBC6qL14WYGlgymaSMQ==", + "dev": true, + "requires": { + "@volar/language-core": "1.11.1", + "path-browserify": "^1.0.1" + } + }, + "@vue/compiler-core": { + "version": "3.4.15", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.15.tgz", + "integrity": "sha512-XcJQVOaxTKCnth1vCxEChteGuwG6wqnUHxAm1DO3gCz0+uXKaJNx8/digSz4dLALCy8n2lKq24jSUs8segoqIw==", + "requires": { + "@babel/parser": "^7.23.6", + "@vue/shared": "3.4.15", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.0.2" + } + }, + "@vue/compiler-dom": { + "version": "3.4.15", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.15.tgz", + "integrity": "sha512-wox0aasVV74zoXyblarOM3AZQz/Z+OunYcIHe1OsGclCHt8RsRm04DObjefaI82u6XDzv+qGWZ24tIsRAIi5MQ==", + "requires": { + "@vue/compiler-core": "3.4.15", + "@vue/shared": "3.4.15" + } + }, + "@vue/compiler-sfc": { + "version": "3.4.15", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.15.tgz", + "integrity": "sha512-LCn5M6QpkpFsh3GQvs2mJUOAlBQcCco8D60Bcqmf3O3w5a+KWS5GvYbrrJBkgvL1BDnTp+e8q0lXCLgHhKguBA==", + "requires": { + "@babel/parser": "^7.23.6", + "@vue/compiler-core": "3.4.15", + "@vue/compiler-dom": "3.4.15", + "@vue/compiler-ssr": "3.4.15", + "@vue/shared": "3.4.15", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.5", + "postcss": "^8.4.33", + "source-map-js": "^1.0.2" + } + }, + "@vue/compiler-ssr": { + "version": "3.4.15", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.15.tgz", + "integrity": "sha512-1jdeQyiGznr8gjFDadVmOJqZiLNSsMa5ZgqavkPZ8O2wjHv0tVuAEsw5hTdUoUW4232vpBbL/wJhzVW/JwY1Uw==", + "requires": { + "@vue/compiler-dom": "3.4.15", + "@vue/shared": "3.4.15" + } + }, + "@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==" + }, + "@vue/language-core": { + "version": "1.8.27", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-1.8.27.tgz", + "integrity": "sha512-L8Kc27VdQserNaCUNiSFdDl9LWT24ly8Hpwf1ECy3aFb9m6bDhBGQYOujDm21N7EW3moKIOKEanQwe1q5BK+mA==", + "dev": true, + "requires": { + "@volar/language-core": "~1.11.1", + "@volar/source-map": "~1.11.1", + "@vue/compiler-dom": "^3.3.0", + "@vue/shared": "^3.3.0", + "computeds": "^0.0.1", + "minimatch": "^9.0.3", + "muggle-string": "^0.3.1", + "path-browserify": "^1.0.1", + "vue-template-compiler": "^2.7.14" + } + }, + "@vue/reactivity": { + "version": "3.4.15", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.15.tgz", + "integrity": "sha512-55yJh2bsff20K5O84MxSvXKPHHt17I2EomHznvFiJCAZpJTNW8IuLj1xZWMLELRhBK3kkFV/1ErZGHJfah7i7w==", + "requires": { + "@vue/shared": "3.4.15" + } + }, + "@vue/runtime-core": { + "version": "3.4.15", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.15.tgz", + "integrity": "sha512-6E3by5m6v1AkW0McCeAyhHTw+3y17YCOKG0U0HDKDscV4Hs0kgNT5G+GCHak16jKgcCDHpI9xe5NKb8sdLCLdw==", + "requires": { + "@vue/reactivity": "3.4.15", + "@vue/shared": "3.4.15" + } + }, + "@vue/runtime-dom": { + "version": "3.4.15", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.15.tgz", + "integrity": "sha512-EVW8D6vfFVq3V/yDKNPBFkZKGMFSvZrUQmx196o/v2tHKdwWdiZjYUBS+0Ez3+ohRyF8Njwy/6FH5gYJ75liUw==", + "requires": { + "@vue/runtime-core": "3.4.15", + "@vue/shared": "3.4.15", + "csstype": "^3.1.3" + } + }, + "@vue/server-renderer": { + "version": "3.4.15", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.15.tgz", + "integrity": "sha512-3HYzaidu9cHjrT+qGUuDhFYvF/j643bHC6uUN9BgM11DVy+pM6ATsG6uPBLnkwOgs7BpJABReLmpL3ZPAsUaqw==", + "requires": { + "@vue/compiler-ssr": "3.4.15", + "@vue/shared": "3.4.15" + } + }, + "@vue/shared": { + "version": "3.4.15", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.15.tgz", + "integrity": "sha512-KzfPTxVaWfB+eGcGdbSf4CWdaXcGDqckoeXUh7SB3fZdEtzPCK2Vq9B/lRRL3yutax/LWITz+SwvgyOxz5V75g==" + }, + "balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0" + } + }, + "computeds": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/computeds/-/computeds-0.0.1.tgz", + "integrity": "sha512-7CEBgcMjVmitjYo5q8JTJVra6X5mQ20uTThdK+0kR7UEaDrAWEQcRiBtWJzga4eRpP6afNwwLsX2SET2JhVB1Q==", + "dev": true + }, + "csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + "de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true + }, + "entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" + }, + "esbuild": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "dev": true, + "requires": { + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" + } + }, + "estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + }, + "fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "optional": true + }, + "he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true + }, + "magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "requires": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "requires": { + "brace-expansion": "^2.0.2" + } + }, + "muggle-string": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.3.1.tgz", + "integrity": "sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg==", + "dev": true + }, + "nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==" + }, + "path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true + }, + "picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "pinia": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.1.7.tgz", + "integrity": "sha512-+C2AHFtcFqjPih0zpYuvof37SFxMQ7OEG2zV9jRI12i9BOy3YQVAHwdKtyyc8pDcDyIc33WCIsZaCFWU7WWxGQ==", + "requires": { + "@vue/devtools-api": "^6.5.0", + "vue-demi": ">=0.14.5" + }, + "dependencies": { + "vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "requires": {} + } + } + }, + "postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "requires": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + } + }, + "rollup": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.30.0.tgz", + "integrity": "sha512-kQvGasUgN+AlWGliFn2POSajRQEsULVYFGTvOZmK06d7vCD+YhZztt70kGk3qaeAXeWYL5eO7zx+rAubBc55eA==", + "dev": true, + "requires": { + "fsevents": "~2.3.2" + } + }, + "semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true + }, + "source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" + }, + "typescript": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", + "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", + "devOptional": true + }, + "vite": { + "version": "4.5.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.14.tgz", + "integrity": "sha512-+v57oAaoYNnO3hIu5Z/tJRZjq5aHM2zDve9YZ8HngVHbhk66RStobhb1sqPMIPEleV6cNKYK4eGrAbE9Ulbl2g==", + "dev": true, + "requires": { + "esbuild": "^0.18.10", + "fsevents": "~2.3.2", + "postcss": "^8.4.27", + "rollup": "^3.27.1" + } + }, + "vue": { + "version": "3.4.15", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.4.15.tgz", + "integrity": "sha512-jC0GH4KkWLWJOEQjOpkqU1bQsBwf4R1rsFtw5GQJbjHVKWDzO6P0nWWBTmjp1xSemAioDFj1jdaK1qa3DnMQoQ==", + "requires": { + "@vue/compiler-dom": "3.4.15", + "@vue/compiler-sfc": "3.4.15", + "@vue/runtime-dom": "3.4.15", + "@vue/server-renderer": "3.4.15", + "@vue/shared": "3.4.15" + } + }, + "vue-router": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.2.5.tgz", + "integrity": "sha512-DIUpKcyg4+PTQKfFPX88UWhlagBEBEfJ5A8XDXRJLUnZOvcpMF8o/dnL90vpVkGaPbjvXazV/rC1qBKrZlFugw==", + "requires": { + "@vue/devtools-api": "^6.5.0" + } + }, + "vue-template-compiler": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.7.16.tgz", + "integrity": "sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==", + "dev": true, + "requires": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "vue-tsc": { + "version": "1.8.27", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-1.8.27.tgz", + "integrity": "sha512-WesKCAZCRAbmmhuGl3+VrdWItEvfoFIPXOvUJkjULi+x+6G/Dy69yO3TBRJDr9eUlmsNAwVmxsNZxvHKzbkKdg==", + "dev": true, + "requires": { + "@volar/typescript": "~1.11.1", + "@vue/language-core": "1.8.27", + "semver": "^7.5.4" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..f019e5b --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,26 @@ +{ + "name": "financial-system-frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1 --port 5173", + "build": "vue-tsc --noEmit && vite build", + "preview": "vite preview --host 127.0.0.1 --port 4173" + }, + "dependencies": { + "@lucide/vue": "1.18.0", + "pinia": "2.1.7", + "vue": "3.4.15", + "vue-router": "4.2.5" + }, + "devDependencies": { + "@vitejs/plugin-vue": "4.6.2", + "typescript": "5.3.3", + "vite": "4.5.14", + "vue-tsc": "1.8.27" + }, + "engines": { + "node": ">=16.14.0" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..7c2aa3f --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,3 @@ + diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 0000000..30242ef --- /dev/null +++ b/frontend/src/api/auth.ts @@ -0,0 +1,55 @@ +import { apiFetch } from "./http"; +import type { + ChangePasswordRequest, + ChangePasswordResponse, + CreateUserRequest, + LoginResponse, + UpdateProfileRequest, + UserProfile, + UserRecord, +} from "./types"; + +export function login(username: string, password: string): Promise { + return apiFetch("/api/auth/login", { + method: "POST", + body: JSON.stringify({ username, password }), + }); +} + +export function getCurrentUser(): Promise { + return apiFetch("/api/auth/me"); +} + +export function listUsers(): Promise { + return apiFetch("/api/auth/users"); +} + +export function createUser(payload: CreateUserRequest): Promise { + return apiFetch("/api/auth/users", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export function updateProfile(payload: UpdateProfileRequest): Promise { + return apiFetch("/api/auth/me", { + method: "PUT", + body: JSON.stringify(payload), + }); +} + +export function changePassword(payload: ChangePasswordRequest): Promise { + return apiFetch("/api/auth/me/password", { + method: "PUT", + body: JSON.stringify(payload), + }); +} + +export function uploadAvatar(file: File): Promise { + const formData = new FormData(); + formData.append("file", file); + return apiFetch("/api/auth/me/avatar", { + method: "POST", + body: formData, + }); +} diff --git a/frontend/src/api/commissions.ts b/frontend/src/api/commissions.ts new file mode 100644 index 0000000..25bfa6e --- /dev/null +++ b/frontend/src/api/commissions.ts @@ -0,0 +1,53 @@ +import { apiFetch } from "./http"; +import type { + CommissionImportResponse, + CommissionListResponse, + CommissionRecord, + CommissionRecordRequest, +} from "./types"; + +export interface CommissionQuery { + page?: number; + page_size?: number; + commission_month?: string; + keyword?: string; +} + +export function listCommissions(query: CommissionQuery): Promise { + const params = new URLSearchParams(); + Object.entries(query).forEach(([key, value]) => { + if (value !== undefined && value !== "") { + params.set(key, String(value)); + } + }); + return apiFetch(`/api/commissions?${params.toString()}`); +} + +export function createCommission(request: CommissionRecordRequest): Promise { + return apiFetch("/api/commissions", { + method: "POST", + body: JSON.stringify(request), + }); +} + +export function updateCommission(id: number, request: CommissionRecordRequest): Promise { + return apiFetch(`/api/commissions/${id}`, { + method: "PUT", + body: JSON.stringify(request), + }); +} + +export function deleteCommission(id: number): Promise<{ message: string }> { + return apiFetch<{ message: string }>(`/api/commissions/${id}`, { + method: "DELETE", + }); +} + +export function importCommissions(file: File): Promise { + const form = new FormData(); + form.append("file", file); + return apiFetch("/api/commissions/import", { + method: "POST", + body: form, + }); +} diff --git a/frontend/src/api/configs.ts b/frontend/src/api/configs.ts new file mode 100644 index 0000000..9c5a23b --- /dev/null +++ b/frontend/src/api/configs.ts @@ -0,0 +1,24 @@ +import { apiFetch } from "./http"; +import type { SalaryConfigRecord, SalaryConfigRequest } from "./types"; + +export function listSalaryConfigs(configGroup = ""): Promise { + const params = new URLSearchParams(); + if (configGroup) { + params.set("config_group", configGroup); + } + const query = params.toString(); + return apiFetch(`/api/configs${query ? `?${query}` : ""}`); +} + +export function updateSalaryConfig(configKey: string, request: SalaryConfigRequest): Promise { + return apiFetch(`/api/configs/${encodeURIComponent(configKey)}`, { + method: "PUT", + body: JSON.stringify(request), + }); +} + +export function ensureDefaultConfigs(): Promise<{ created_count: number; message: string }> { + return apiFetch<{ created_count: number; message: string }>("/api/configs/defaults", { + method: "POST", + }); +} diff --git a/frontend/src/api/employees.ts b/frontend/src/api/employees.ts new file mode 100644 index 0000000..5b187b2 --- /dev/null +++ b/frontend/src/api/employees.ts @@ -0,0 +1,48 @@ +import { apiFetch } from "./http"; +import type { EmployeeListResponse, EmployeeNoResponse, EmployeeRecord, EmployeeRequest } from "./types"; + +export function listEmployees(params: { keyword?: string; employment_status?: string } = {}): Promise { + const search = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && String(value).trim() !== "") { + search.set(key, String(value)); + } + }); + const query = search.toString(); + return apiFetch(`/api/employees${query ? `?${query}` : ""}`); +} + +export function listEmployeePage( + params: { page?: number; page_size?: number; keyword?: string; employment_status?: string } = {}, +): Promise { + const search = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && String(value).trim() !== "") { + search.set(key, String(value)); + } + }); + const query = search.toString(); + return apiFetch(`/api/employees/page${query ? `?${query}` : ""}`); +} + +export function getEmployee(employeeId: number): Promise { + return apiFetch(`/api/employees/${employeeId}`); +} + +export function previewNextEmployeeNo(): Promise { + return apiFetch("/api/employees/next-no"); +} + +export function createEmployee(payload: EmployeeRequest): Promise { + return apiFetch("/api/employees", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export function updateEmployee(employeeId: number, payload: EmployeeRequest): Promise { + return apiFetch(`/api/employees/${employeeId}`, { + method: "PATCH", + body: JSON.stringify(payload), + }); +} diff --git a/frontend/src/api/http.ts b/frontend/src/api/http.ts new file mode 100644 index 0000000..675574f --- /dev/null +++ b/frontend/src/api/http.ts @@ -0,0 +1,68 @@ +const TOKEN_KEY = "financial-system-token"; + +export class ApiError extends Error { + constructor( + message: string, + public readonly status: number, + ) { + super(message); + this.name = "ApiError"; + } +} + +export function getStoredToken(): string { + return localStorage.getItem(TOKEN_KEY) || ""; +} + +export function setStoredToken(token: string): void { + localStorage.setItem(TOKEN_KEY, token); +} + +export function clearStoredToken(): void { + localStorage.removeItem(TOKEN_KEY); +} + +export function buildApiUrl(path: string): string { + const base = (import.meta.env.VITE_API_BASE_URL || "").replace(/\/$/, ""); + return `${base}${path.startsWith("/") ? path : `/${path}`}`; +} + +export async function apiFetch(path: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers); + const token = getStoredToken(); + + if (token) { + headers.set("Authorization", `Bearer ${token}`); + } + + if (init.body && !(init.body instanceof FormData) && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + + const response = await fetch(buildApiUrl(path), { + ...init, + headers, + }); + + if (!response.ok) { + throw new ApiError(await readErrorMessage(response), response.status); + } + + if (response.status === 204) { + return undefined as T; + } + + return (await response.json()) as T; +} + +async function readErrorMessage(response: Response): Promise { + try { + const payload = await response.json(); + if (typeof payload.detail === "string") { + return payload.detail; + } + return JSON.stringify(payload.detail || payload); + } catch { + return `请求失败,状态码 ${response.status}`; + } +} diff --git a/frontend/src/api/operationLogs.ts b/frontend/src/api/operationLogs.ts new file mode 100644 index 0000000..e0c893a --- /dev/null +++ b/frontend/src/api/operationLogs.ts @@ -0,0 +1,15 @@ +import { apiFetch } from "./http"; +import type { OperationLogListResponse, OperationLogQuery } from "./types"; + +export function listOperationLogs(query: OperationLogQuery = {}): Promise { + const params = new URLSearchParams(); + + Object.entries(query).forEach(([key, value]) => { + if (value !== undefined && value !== null && String(value).trim() !== "") { + params.set(key, String(value)); + } + }); + + const search = params.toString(); + return apiFetch(`/api/operation-logs${search ? `?${search}` : ""}`); +} diff --git a/frontend/src/api/organization.ts b/frontend/src/api/organization.ts new file mode 100644 index 0000000..73576d9 --- /dev/null +++ b/frontend/src/api/organization.ts @@ -0,0 +1,55 @@ +import { apiFetch } from "./http"; +import type { DepartmentRecord, DepartmentRequest, PositionRecord, PositionRequest } from "./types"; + +export function listDepartments(activeOnly = true, keyword = ""): Promise { + const params = new URLSearchParams(); + params.set("active_only", String(activeOnly)); + if (keyword.trim()) { + params.set("keyword", keyword.trim()); + } + return apiFetch(`/api/organization/departments?${params.toString()}`); +} + +export function createDepartment(request: DepartmentRequest): Promise { + return apiFetch("/api/organization/departments", { + method: "POST", + body: JSON.stringify(request), + }); +} + +export function updateDepartment(id: number, request: DepartmentRequest): Promise { + return apiFetch(`/api/organization/departments/${id}`, { + method: "PUT", + body: JSON.stringify(request), + }); +} + +export function listPositions( + departmentId?: number, + activeOnly = true, + keyword = "", +): Promise { + const params = new URLSearchParams(); + params.set("active_only", String(activeOnly)); + if (departmentId) { + params.set("department_id", String(departmentId)); + } + if (keyword.trim()) { + params.set("keyword", keyword.trim()); + } + return apiFetch(`/api/organization/positions?${params.toString()}`); +} + +export function createPosition(request: PositionRequest): Promise { + return apiFetch("/api/organization/positions", { + method: "POST", + body: JSON.stringify(request), + }); +} + +export function updatePosition(id: number, request: PositionRequest): Promise { + return apiFetch(`/api/organization/positions/${id}`, { + method: "PUT", + body: JSON.stringify(request), + }); +} diff --git a/frontend/src/api/payroll.ts b/frontend/src/api/payroll.ts new file mode 100644 index 0000000..de560fc --- /dev/null +++ b/frontend/src/api/payroll.ts @@ -0,0 +1,98 @@ +import { ApiError, apiFetch, buildApiUrl, getStoredToken } from "./http"; +import type { + DingTalkPayrollRequest, + PayrollJobResponse, + PayrollResponse, + RecentPayrollJob, +} from "./types"; + +const RECENT_JOBS_KEY = "financial-system-recent-payroll-jobs"; + +export function calculateFromExcel( + file: File, + exportExcel: boolean, + configPath?: string, +): Promise { + const formData = new FormData(); + formData.append("file", file); + formData.append("export_excel", String(exportExcel)); + if (configPath) { + formData.append("config_path", configPath); + } + + return apiFetch("/api/payroll/excel", { + method: "POST", + body: formData, + }); +} + +export function calculateFromDingTalk(payload: DingTalkPayrollRequest): Promise { + return apiFetch("/api/payroll/dingtalk/realtime", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export function getPayrollJob(jobId: string): Promise { + return apiFetch(`/api/payroll/jobs/${encodeURIComponent(jobId)}`); +} + +export function getDownloadUrl(downloadUrl: string | null, outputFile?: string | null): string { + if (downloadUrl) { + return buildApiUrl(downloadUrl); + } + + const filename = outputFile?.split(/[\\/]/).pop(); + return filename ? buildApiUrl(`/api/payroll/download/${encodeURIComponent(filename)}`) : ""; +} + +export async function downloadPayrollFile(downloadUrl: string | null, outputFile?: string | null): Promise { + const url = getDownloadUrl(downloadUrl, outputFile); + if (!url) { + throw new Error("没有可下载的结果文件"); + } + + const headers = new Headers(); + const token = getStoredToken(); + if (token) { + headers.set("Authorization", `Bearer ${token}`); + } + + const response = await fetch(url, { headers }); + if (!response.ok) { + throw new ApiError("下载失败,请确认文件仍然存在", response.status); + } + + const blob = await response.blob(); + const objectUrl = URL.createObjectURL(blob); + const filename = outputFile?.split(/[\\/]/).pop() || "工资计算结果.xlsx"; + const anchor = document.createElement("a"); + anchor.href = objectUrl; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(objectUrl); +} + +export function readRecentJobs(): RecentPayrollJob[] { + try { + const raw = localStorage.getItem(RECENT_JOBS_KEY); + return raw ? (JSON.parse(raw) as RecentPayrollJob[]) : []; + } catch { + return []; + } +} + +export function saveRecentJob(sourceType: string, response: PayrollResponse): void { + const nextJob: RecentPayrollJob = { + job_id: response.job_id, + source_type: sourceType, + employee_count: response.employee_count, + output_file: response.output_file, + download_url: response.download_url, + created_at: new Date().toISOString(), + }; + const jobs = [nextJob, ...readRecentJobs().filter((item) => item.job_id !== response.job_id)].slice(0, 12); + localStorage.setItem(RECENT_JOBS_KEY, JSON.stringify(jobs)); +} diff --git a/frontend/src/api/reports.ts b/frontend/src/api/reports.ts new file mode 100644 index 0000000..8c88a03 --- /dev/null +++ b/frontend/src/api/reports.ts @@ -0,0 +1,11 @@ +import { apiFetch } from "./http"; +import type { PayrollReportResponse } from "./types"; + +export function getPayrollReport(salaryMonth = ""): Promise { + const params = new URLSearchParams(); + if (salaryMonth) { + params.set("salary_month", salaryMonth); + } + const query = params.toString(); + return apiFetch(`/api/reports/payroll-summary${query ? `?${query}` : ""}`); +} diff --git a/frontend/src/api/salaryProfiles.ts b/frontend/src/api/salaryProfiles.ts new file mode 100644 index 0000000..ec9a421 --- /dev/null +++ b/frontend/src/api/salaryProfiles.ts @@ -0,0 +1,18 @@ +import { apiFetch } from "./http"; +import type { SalaryProfileRecord, SalaryProfileRequest } from "./types"; + +export function listSalaryProfiles(keyword = ""): Promise { + const search = new URLSearchParams(); + if (keyword.trim()) { + search.set("keyword", keyword.trim()); + } + const query = search.toString(); + return apiFetch(`/api/salary-profiles${query ? `?${query}` : ""}`); +} + +export function saveSalaryProfile(payload: SalaryProfileRequest): Promise { + return apiFetch(`/api/salary-profiles/${payload.employee_id}`, { + method: "PUT", + body: JSON.stringify(payload), + }); +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts new file mode 100644 index 0000000..642c69a --- /dev/null +++ b/frontend/src/api/types.ts @@ -0,0 +1,378 @@ +export interface MenuItem { + code: string; + name: string; + path: string; +} + +export type UserRole = "superuser" | "manager" | "viewer"; + +export interface UserProfile { + id: number; + username: string; + display_name: string; + avatar_url: string; + email: string; + phone: string; + department: string; + position_title: string; + role: UserRole; + is_active: boolean; + menus: MenuItem[]; + permissions: string[]; +} + +export interface UserRecord extends UserProfile { + created_at: string; + updated_at: string; +} + +export interface LoginResponse { + access_token: string; + token_type: string; + user: UserProfile; + menus: MenuItem[]; + permissions: string[]; +} + +export interface CreateUserRequest { + username: string; + password: string; + role: UserRole; + display_name: string; + email: string; + phone: string; + department: string; + position_title: string; +} + +export interface UpdateProfileRequest { + display_name: string; + email: string; + phone: string; + department: string; + position_title: string; +} + +export interface ChangePasswordRequest { + old_password: string; + new_password: string; + confirm_password: string; +} + +export interface ChangePasswordResponse { + message: string; +} + +export interface PayrollResult { + name: string; + department: string; + position: string; + attendance_group: string; + salary_month: string; + salary_mode: string; + attendance_days: number; + absence_days: number; + actual_work_hours: number; + punch_overtime_hours: number; + leave_hours: number; + remaining_overtime_hours: number; + workday_overtime_hours: number; + weekend_overtime_hours: number; + holiday_overtime_hours: number; + minor_late_count: number; + major_late_count: number; + penalized_late_count: number; + late_deduction: number; + missing_card_count: number; + missing_card_deduction: number; + total_deduction: number; + base_salary: number | null; + base_pay: number | null; + commission_amount: number; + overtime_rate: number; + weekend_overtime_rate: number; + holiday_overtime_rate: number; + overtime_pay: number; + gross_salary: number | null; + net_salary: number | null; + note: string; +} + +export interface PayrollResponse { + job_id: string; + employee_count: number; + output_file: string | null; + download_url: string | null; + results: PayrollResult[]; +} + +export interface PayrollJobResponse { + job_id: string; + source_type: string; + status: string; + input_file: string | null; + output_file: string | null; + employee_count: number; + error_message: string | null; + created_at: string; + updated_at: string; + results: PayrollResult[]; +} + +export interface DingTalkPayrollRequest { + user_ids: string[]; + start_date: string; + end_date: string; + config_path?: string | null; + export_excel: boolean; +} + +export interface RecentPayrollJob { + job_id: string; + source_type: string; + employee_count: number; + output_file: string | null; + download_url: string | null; + created_at: string; +} + +export interface OperationLog { + id: number; + user_id: number | null; + username: string; + role: string; + module: string; + action: string; + target_type: string; + target_id: string; + status: string; + detail: string; + ip_address: string; + user_agent: string; + created_at: string; +} + +export interface OperationLogListResponse { + items: OperationLog[]; + total: number; + page: number; + page_size: number; +} + +export interface OperationLogQuery { + page?: number; + page_size?: number; + username?: string; + module?: string; + action?: string; + status?: string; + keyword?: string; + start_time?: string; + end_time?: string; +} + +export type EmploymentStatus = "active" | "inactive"; + +export interface EmployeeRecord { + id: number; + employee_no: string; + name: string; + dingtalk_user_id: string; + department: string; + position: string; + phone: string; + employment_status: EmploymentStatus; + remark: string; + created_at: string; + updated_at: string; +} + +export interface EmployeeListResponse { + items: EmployeeRecord[]; + total: number; + page: number; + page_size: number; +} + +export interface EmployeeNoResponse { + employee_no: string; +} + +export interface EmployeeRequest { + employee_no: string; + name: string; + dingtalk_user_id: string; + department: string; + position: string; + phone: string; + employment_status: EmploymentStatus; + remark: string; +} + +export interface DepartmentRecord { + id: number; + parent_id: number | null; + department_code: string; + name: string; + sort_order: number; + is_active: boolean; + remark: string; + created_at: string; + updated_at: string; +} + +export interface DepartmentRequest { + parent_id: number | null; + department_code: string; + name: string; + sort_order: number; + is_active: boolean; + remark: string; +} + +export interface PositionRecord { + id: number; + department_id: number; + department: DepartmentRecord; + position_code: string; + name: string; + sort_order: number; + is_active: boolean; + remark: string; + created_at: string; + updated_at: string; +} + +export interface PositionRequest { + department_id: number; + position_code?: string; + name: string; + sort_order: number; + is_active: boolean; + remark: string; +} + +export interface SalaryProfileRecord { + id: number; + employee_id: number; + employee: EmployeeRecord; + salary_mode: string; + base_salary: number; + hourly_rate: number; + piece_quantity: number; + piece_unit_price: number; + probation_type: string; + probation_ratio: number; + probation_salary: number; + commission_amount: number; + overtime_rate: number; + weekend_overtime_rate: number; + holiday_overtime_rate: number; + effective_month: string; + remark: string; + created_at: string; + updated_at: string; +} + +export interface SalaryProfileRequest { + employee_id: number; + salary_mode: string; + base_salary: number; + hourly_rate: number; + piece_quantity: number; + piece_unit_price: number; + probation_type: string; + probation_ratio: number; + probation_salary: number; + commission_amount: number; + overtime_rate: number; + weekend_overtime_rate: number; + holiday_overtime_rate: number; + effective_month: string; + remark: string; +} + +export interface CommissionRecord { + id: number; + employee_id: number; + employee: EmployeeRecord; + commission_month: string; + commission_type: string; + amount: number; + source_type: string; + business_ref: string; + remark: string; + created_at: string; + updated_at: string; +} + +export interface CommissionListResponse { + items: CommissionRecord[]; + total: number; + page: number; + page_size: number; +} + +export interface CommissionRecordRequest { + employee_id: number; + commission_month: string; + commission_type: string; + amount: number; + source_type: string; + business_ref: string; + remark: string; +} + +export interface CommissionImportResponse { + imported_count: number; + message: string; +} + +export interface SalaryConfigRecord { + id: number; + config_key: string; + config_name: string; + config_group: string; + config_value: string; + value_type: string; + is_enabled: boolean; + remark: string; + created_at: string; + updated_at: string; +} + +export interface SalaryConfigRequest { + config_name: string; + config_group: string; + config_value: string; + value_type: string; + is_enabled: boolean; + remark: string; +} + +export interface PayrollReportResponse { + salary_month: string | null; + totals: Record; + attendance: Record; + departments: Array<{ + department: string; + employee_count: number; + gross_salary: number; + net_salary: number; + }>; + recent_records: Array<{ + id: number; + employee_no: string; + employee_name: string; + department: string; + salary_month: string; + salary_mode: string; + base_pay: number; + commission_amount: number; + overtime_pay: number; + deduction_amount: number; + gross_salary: number; + net_salary: number; + created_at: string; + }>; +} diff --git a/frontend/src/assets/styles.css b/frontend/src/assets/styles.css new file mode 100644 index 0000000..a4388f3 --- /dev/null +++ b/frontend/src/assets/styles.css @@ -0,0 +1,2354 @@ +:root { + --surface: #f7f9fb; + --surface-soft: #eef2f6; + --surface-strong: #e3e8ee; + --card: #ffffff; + --primary: #041627; + --primary-soft: #1a2b3c; + --text: #191c1e; + --muted: #5f6670; + --line: #d8dee6; + --line-soft: #edf1f5; + --blue: #0f62c6; + --blue-soft: #e8f1ff; + --green: #008c61; + --green-soft: #ddf8ee; + --red: #bd1e1e; + --red-soft: #ffebe8; + --shadow: 0 16px 32px rgba(4, 22, 39, 0.06); + --sidebar-bg: #f3f6f9; + --nav-text: #303842; + --nav-active-bg: #e1e6ec; + --topbar-bg: rgba(247, 249, 251, 0.94); + --search-bg: #f1f4f8; + --control-bg: #fbfcfe; + --secondary-bg: #e8edf2; + --secondary-hover-bg: #dde5ed; + --secondary-hover-border: #c6d0db; + --table-header-bg: #f5f7fa; + --table-row-hover-bg: #fafcff; + --modal-backdrop: rgba(4, 22, 39, 0.32); + --primary-rgb: 4, 22, 39; + --accent-rgb: 15, 98, 198; + --app-font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + "PingFang SC", "Microsoft YaHei", sans-serif; + --radius-sm: 7px; + --radius-md: 8px; + --radius-lg: 12px; + --topbar-height: 72px; + --nav-height: 48px; + --control-height: 44px; + --base-font-size: 16px; + --page-padding: 32px; + --content-gap: 24px; + --panel-padding: 24px; + --table-cell-y: 18px; + --table-cell-x: 24px; + --layout-sidebar-width: 280px; + --layout-sidebar-offset: 280px; + --collapsed-sidebar-width: 88px; + --collapsed-sidebar-offset: 88px; + --content-max-width: none; + --motion-duration: 280ms; + --motion-duration-fast: 180ms; + --motion-easing: cubic-bezier(0.22, 1, 0.36, 1); + color: var(--text); + background: var(--surface); + font-family: var(--app-font-family); +} + +* { + box-sizing: border-box; +} + +html, +body, +#app { + min-height: 100%; +} + +body { + margin: 0; + background: var(--surface); + color: var(--text); + font-size: var(--base-font-size); + -webkit-font-smoothing: antialiased; +} + +button, +input, +select, +textarea { + font: inherit; +} + +button { + cursor: pointer; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.62; +} + +a { + color: inherit; + text-decoration: none; +} + +.app-shell { + min-height: 100vh; + background: var(--surface); +} + +.main-shell { + min-height: 100vh; + margin-left: var(--layout-sidebar-offset); + transition: margin-left var(--motion-duration) var(--motion-easing); + will-change: margin-left; +} + +:root[data-sidebar="floating"] .app-shell { + --collapsed-sidebar-offset: calc(var(--collapsed-sidebar-width) + 24px); +} + +.app-shell.is-sidebar-collapsed .main-shell { + margin-left: var(--collapsed-sidebar-offset); +} + +.page-shell { + padding: var(--page-padding); +} + +.page-shell > .view-stack { + max-width: var(--content-max-width); + margin-inline: auto; +} + +.sidebar { + position: fixed; + inset: 0 auto 0 0; + z-index: 30; + display: flex; + width: var(--layout-sidebar-width); + flex-direction: column; + overflow: hidden; + border-right: 1px solid var(--line); + background: var(--sidebar-bg); + transition: + width var(--motion-duration) var(--motion-easing), + background var(--motion-duration-fast) ease, + border-color var(--motion-duration-fast) ease, + box-shadow var(--motion-duration-fast) ease, + transform var(--motion-duration) var(--motion-easing); + will-change: width, transform; +} + +.app-shell.is-sidebar-collapsed .sidebar { + width: var(--collapsed-sidebar-width); +} + +:root[data-sidebar="floating"] .sidebar { + inset: 16px auto 16px 16px; + border: 1px solid var(--line); + border-radius: var(--radius-lg); + box-shadow: var(--shadow); +} + +:root[data-sidebar="edge"] .sidebar { + border-right: 0; + background: var(--primary); +} + +:root[data-sidebar="edge"] .brand-title, +:root[data-sidebar="edge"] .brand-subtitle, +:root[data-sidebar="edge"] .nav-link { + color: white; +} + +:root[data-sidebar="edge"] .brand-mark { + background: rgba(255, 255, 255, 0.16); +} + +:root[data-sidebar="edge"] .nav-link:hover, +:root[data-sidebar="edge"] .nav-link.is-active { + background: rgba(255, 255, 255, 0.16); + color: white; +} + +:root[data-layout="fullscreen"] .main-shell { + margin-left: 0; +} + +:root[data-layout="fullscreen"] .sidebar { + transform: translateX(-110%); +} + +:root[data-layout="fullscreen"] .sidebar.is-open { + transform: translateX(0); +} + +:root[data-layout="fullscreen"] .mobile-menu { + display: grid; +} + +.brand { + display: flex; + align-items: center; + gap: 12px; + padding: calc(var(--panel-padding) + 8px) var(--panel-padding) var(--panel-padding); + transition: + gap var(--motion-duration) var(--motion-easing), + padding var(--motion-duration) var(--motion-easing); +} + +.brand-main { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.brand-copy { + min-width: 0; + max-width: 160px; + overflow: hidden; + opacity: 1; + transform: translateX(0); + white-space: nowrap; + transition: + max-width var(--motion-duration) var(--motion-easing), + opacity var(--motion-duration-fast) ease, + transform var(--motion-duration) var(--motion-easing); +} + +.brand-mark { + display: grid; + width: 40px; + height: 40px; + place-items: center; + flex: 0 0 auto; + border-radius: var(--radius-md); + background: var(--primary); + color: white; + box-shadow: 0 8px 18px rgba(var(--primary-rgb), 0.2); + transition: + width var(--motion-duration) var(--motion-easing), + height var(--motion-duration) var(--motion-easing), + border-radius var(--motion-duration-fast) ease, + box-shadow var(--motion-duration-fast) ease; +} + +.brand-mark.large { + width: 52px; + height: 52px; +} + +.brand-title { + margin: 0; + color: var(--primary); + font-size: 18px; + font-weight: 800; +} + +.brand-subtitle { + margin: 4px 0 0; + color: var(--muted); + font-size: 12px; +} + +.sidebar-nav { + display: grid; + flex: 1; + align-content: start; + gap: 8px; + min-height: 0; + overflow-y: auto; + padding: 16px; + transition: + gap var(--motion-duration) var(--motion-easing), + padding var(--motion-duration) var(--motion-easing); +} + +.sidebar-nav::-webkit-scrollbar { + width: 8px; +} + +.sidebar-nav::-webkit-scrollbar-thumb { + border: 2px solid var(--sidebar-bg); + border-radius: 999px; + background: var(--surface-strong); +} + +.app-shell.is-sidebar-collapsed .brand { + justify-content: center; + gap: 4px; + padding-inline: 8px; +} + +.app-shell.is-sidebar-collapsed .brand-main { + justify-content: center; +} + +.app-shell.is-sidebar-collapsed .brand-copy, +.app-shell.is-sidebar-collapsed .nav-link span { + max-width: 0; + opacity: 0; + pointer-events: none; + transform: translateX(-8px); +} + +.app-shell.is-sidebar-collapsed .brand-mark { + width: 36px; + height: 36px; +} + +.app-shell.is-sidebar-collapsed .sidebar-nav, +.app-shell.is-sidebar-collapsed .sidebar-footer { + padding-inline: 14px; +} + +.app-shell.is-sidebar-collapsed .nav-link { + justify-content: center; + gap: 0; + padding-inline: 0; +} + +.nav-link { + display: flex; + align-items: center; + gap: 12px; + min-height: var(--nav-height); + width: 100%; + border: 0; + border-radius: var(--radius-md); + background: transparent; + color: var(--nav-text); + padding: 0 16px; + text-align: left; + transition: + background var(--motion-duration-fast) ease, + color var(--motion-duration-fast) ease, + gap var(--motion-duration) var(--motion-easing), + padding var(--motion-duration) var(--motion-easing), + transform var(--motion-duration-fast) ease; +} + +.nav-link span { + max-width: 120px; + overflow: hidden; + opacity: 1; + transform: translateX(0); + white-space: nowrap; + transition: + max-width var(--motion-duration) var(--motion-easing), + opacity var(--motion-duration-fast) ease, + transform var(--motion-duration) var(--motion-easing); +} + +.nav-link:hover, +.nav-link.is-active { + background: var(--nav-active-bg); + color: var(--blue); +} + +.nav-link.is-active { + font-weight: 700; +} + +.nav-button { + appearance: none; +} + +.nav-link.danger { + color: var(--red); +} + +.sidebar-footer { + display: grid; + flex: 0 0 auto; + gap: 6px; + border-top: 1px solid var(--line); + padding: 16px; + transition: + gap var(--motion-duration) var(--motion-easing), + padding var(--motion-duration) var(--motion-easing); +} + +.topbar { + position: sticky; + top: 0; + z-index: 20; + display: flex; + min-height: var(--topbar-height); + align-items: center; + justify-content: space-between; + gap: var(--content-gap); + border-bottom: 1px solid var(--line-soft); + background: var(--topbar-bg); + padding: 0 var(--page-padding); + backdrop-filter: blur(14px); +} + +.topbar-left, +.topbar-actions { + display: flex; + align-items: center; + gap: 16px; +} + +.global-search { + display: flex; + width: min(520px, 40vw); + min-width: 260px; + align-items: center; + gap: 10px; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--search-bg); + padding: 0 16px; + color: var(--muted); +} + +.global-search input { + min-height: var(--control-height); + width: 100%; + border: 0; + background: transparent; + color: var(--text); + outline: 0; +} + +.icon-button { + position: relative; + display: grid; + width: 42px; + height: 42px; + place-items: center; + border: 0; + border-radius: 999px; + background: transparent; + color: var(--nav-text); +} + +.icon-button:hover { + background: var(--surface-strong); + color: var(--blue); +} + +.mobile-menu { + display: none; +} + +.notice-dot { + position: absolute; + top: 9px; + right: 10px; + width: 8px; + height: 8px; + border: 2px solid var(--surface); + border-radius: 999px; + background: var(--red); +} + +.user-summary { + display: flex; + align-items: center; + gap: 12px; + border-left: 1px solid var(--line); + padding-left: 20px; +} + +.profile-trigger { + border-top: 0; + border-right: 0; + border-bottom: 0; + background: transparent; + color: inherit; + text-align: left; +} + +.profile-trigger:hover .avatar { + border-color: var(--blue); +} + +.user-text { + display: grid; + gap: 2px; + text-align: right; +} + +.user-text strong { + color: var(--primary); + font-size: 14px; +} + +.user-text span { + color: var(--muted); + font-size: 12px; +} + +.avatar { + display: grid; + width: 42px; + height: 42px; + place-items: center; + border: 2px solid var(--line); + border-radius: 999px; + background: var(--card); + color: var(--primary); + font-size: 13px; + font-weight: 800; + overflow: hidden; +} + +.avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.avatar.large { + width: 76px; + height: 76px; + font-size: 20px; +} + +.appearance-panel-layer { + position: fixed; + inset: 0; + z-index: 90; + display: grid; + align-items: start; + justify-items: end; + pointer-events: auto; +} + +.appearance-panel { + width: min(560px, calc(100vw - 20px)); + max-height: 100vh; + margin: 0; + overflow-y: auto; + border: 1px solid var(--line); + border-radius: 0 0 0 var(--radius-lg); + background: var(--card); + box-shadow: 0 28px 70px rgba(var(--primary-rgb), 0.26); + color: var(--text); + padding: 16px; + transform: translateX(0); + transition: + opacity var(--motion-duration-fast) ease, + transform var(--motion-duration) var(--motion-easing); + will-change: transform, opacity; +} + +.drawer-slide-enter-active, +.drawer-slide-leave-active { + transition: opacity var(--motion-duration) ease; +} + +.drawer-slide-enter-from, +.drawer-slide-leave-to { + opacity: 0; +} + +.drawer-slide-enter-from .appearance-panel, +.drawer-slide-leave-to .appearance-panel { + opacity: 0; + transform: translateX(28px); +} + +.drawer-slide-enter-to .appearance-panel, +.drawer-slide-leave-from .appearance-panel { + opacity: 1; + transform: translateX(0); +} + +.fade-enter-active, +.fade-leave-active { + transition: opacity var(--motion-duration-fast) ease; +} + +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} + +:root[data-theme-mode="dark"] .appearance-panel { + box-shadow: 0 28px 70px rgba(0, 0, 0, 0.45); +} + +.appearance-panel::-webkit-scrollbar { + width: 10px; +} + +.appearance-panel::-webkit-scrollbar-thumb { + border: 3px solid var(--card); + border-radius: 999px; + background: var(--surface-strong); +} + +.appearance-panel-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; +} + +.appearance-panel-header .icon-button { + width: 32px; + height: 32px; +} + +.appearance-panel-header .icon-button svg { + width: 18px; + height: 18px; +} + +.appearance-panel-header h2, +.appearance-section-head h3 { + margin: 0; + color: var(--primary); +} + +.appearance-panel-header h2 { + font-size: 18px; +} + +.appearance-panel-header p { + margin: 4px 0 0; + color: var(--muted); + font-size: 13px; +} + +.appearance-section { + display: grid; + gap: 8px; +} + +.appearance-section + .appearance-section { + margin-top: 16px; +} + +.appearance-section-head { + display: flex; + align-items: center; + gap: 8px; +} + +.appearance-section-head h3 { + font-size: 16px; +} + +.appearance-reset { + display: grid; + width: 22px; + height: 22px; + place-items: center; + border: 1px solid transparent; + border-radius: 999px; + background: transparent; + color: var(--muted); +} + +.appearance-reset svg { + width: 14px; + height: 14px; +} + +.appearance-reset:hover { + border-color: var(--line); + background: var(--surface-soft); + color: var(--blue); +} + +.appearance-grid { + display: grid; + gap: 10px; +} + +.mode-grid, +.font-grid, +.sidebar-grid, +.layout-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.color-preset-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.radius-grid { + grid-template-columns: repeat(6, minmax(0, 1fr)); +} + +.density-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.content-width-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.appearance-choice, +.color-preset-choice { + position: relative; + display: grid; + gap: 6px; + min-width: 0; + border: 1px solid var(--line); + border-radius: var(--radius-lg); + background: var(--surface); + color: var(--text); + padding: 6px; + text-align: center; + transition: + background 0.18s ease, + border-color 0.18s ease, + box-shadow 0.18s ease, + transform 0.18s ease; +} + +.appearance-choice:hover, +.color-preset-choice:hover, +.appearance-choice.is-active, +.color-preset-choice.is-active { + border-color: var(--blue); + box-shadow: none; +} + +.appearance-choice strong, +.color-preset-choice strong { + color: var(--primary); + font-size: 13px; + line-height: 1.25; +} + +.choice-check { + position: absolute; + top: -7px; + right: -7px; + display: grid; + width: 24px; + height: 24px; + place-items: center; + border: 2px solid var(--card); + border-radius: 999px; + background: var(--blue); + color: white; + box-shadow: 0 8px 18px rgba(var(--accent-rgb), 0.24); +} + +.choice-check svg { + width: 14px; + height: 14px; +} + +.theme-mode-choice { + align-content: start; + padding: 0; +} + +.mode-preview, +.layout-preview, +.dashboard-preview, +.content-width-preview, +.density-preview, +.radius-preview { + position: relative; + display: block; + overflow: hidden; + border-radius: var(--radius-lg); + background: var(--surface-soft); +} + +.mode-preview { + height: 78px; + border: 1px solid var(--line); +} + +.theme-mode-choice strong { + padding-bottom: 6px; + text-align: left; +} + +.theme-mode-choice strong, +.layout-choice strong, +.content-width-choice strong, +.density-choice strong, +.font-choice strong, +.radius-choice strong { + padding-inline: 2px; +} + +.theme-mode-choice .preview-sidebar { + position: absolute; + inset: 0 auto 0 0; + display: grid; + width: 30%; + align-content: start; + gap: 5px; + background: rgba(var(--primary-rgb), 0.08); + padding: 11px 8px; +} + +.preview-sidebar i, +.density-preview i, +.content-width-preview i { + display: block; + height: 4px; + border-radius: 999px; + background: currentColor; + opacity: 0.55; +} + +.preview-sidebar i:first-child { + width: 17px; +} + +.preview-sidebar i:nth-child(2) { + width: 27px; +} + +.preview-sidebar i:nth-child(3) { + width: 23px; +} + +.preview-chart { + position: absolute; + left: 40%; + top: 29px; + display: flex; + align-items: end; + gap: 4px; + color: var(--blue); +} + +.preview-chart i { + display: block; + width: 5px; + border-radius: 3px 3px 0 0; + background: currentColor; +} + +.preview-chart i:first-child { + height: 11px; +} + +.preview-chart i:nth-child(2) { + height: 16px; +} + +.preview-chart i:nth-child(3) { + height: 24px; +} + +.preview-pie { + position: absolute; + top: 14px; + right: 14px; + width: 28px; + height: 28px; + border-radius: 999px; + background: conic-gradient(var(--blue) 0 32%, var(--surface-strong) 32% 100%); +} + +.preview-card { + position: absolute; + left: 38%; + right: 12px; + bottom: 9px; + height: 28px; + border-radius: var(--radius-md); + background: rgba(var(--primary-rgb), 0.1); +} + +.theme-mode-choice.is-system .mode-preview { + background: linear-gradient(90deg, #252320 0 50%, #f4f2ef 50% 100%); + color: #d8d3ca; +} + +.theme-mode-choice.is-light .mode-preview { + background: #f5f6f8; + color: #a9a9a9; +} + +.theme-mode-choice.is-dark .mode-preview { + background: #12213a; + color: #6b94c6; +} + +.color-preset-choice { + min-height: 68px; + align-content: start; + background: transparent; + border-color: transparent; + padding: 0; +} + +.color-preset-swatch { + display: block; + height: 42px; + border: 1px solid var(--line); + border-radius: var(--radius-lg); + background: linear-gradient(135deg, var(--preset-from), var(--preset-to)); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.18); +} + +.color-preset-choice strong { + text-align: center; +} + +.font-choice { + min-height: 54px; + place-items: center; +} + +.font-choice span:first-child { + color: var(--primary); + font-size: 20px; + font-weight: 800; + line-height: 1; +} + +.font-auto span:first-child { + font-family: var(--app-font-family); +} + +.font-sans span:first-child { + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + "PingFang SC", "Microsoft YaHei", sans-serif; +} + +.font-serif span:first-child { + font-family: Georgia, "Times New Roman", "Songti SC", "SimSun", serif; +} + +.radius-choice { + min-height: 52px; + place-items: center; +} + +.radius-preview { + width: 100%; + height: 24px; + border: 1px solid currentColor; + border-radius: var(--preview-radius); + color: var(--muted); +} + +.radius-preview::before { + content: ""; + position: absolute; + top: 7px; + left: 10px; + width: 16px; + height: 12px; + border-top: 2px solid currentColor; + border-left: 2px solid currentColor; + border-top-left-radius: var(--preview-radius); +} + +.density-choice { + min-height: 54px; +} + +.density-preview { + display: grid; + height: 27px; + align-content: center; + gap: 4px; + color: var(--muted); +} + +.density-preview i:first-child { + width: 72%; +} + +.density-preview i:nth-child(2) { + width: 64%; +} + +.density-preview i:nth-child(3) { + width: 56%; +} + +.density-preview i:nth-child(4) { + width: 48%; +} + +.density-preview i:nth-child(5) { + width: 42%; +} + +.layout-choice { + min-height: 86px; +} + +.layout-preview { + height: 58px; + border: 1px solid var(--line); +} + +.layout-side { + position: absolute; + top: 9px; + bottom: 9px; + left: 9px; + display: grid; + width: 30px; + align-content: start; + gap: 5px; + border-radius: var(--radius-md); + background: rgba(var(--accent-rgb), 0.26); + padding: 8px 6px; +} + +.layout-side i { + display: block; + height: 5px; + border-radius: 999px; + background: var(--primary); + opacity: 0.62; +} + +.layout-side i:first-child { + width: 15px; +} + +.layout-side i:nth-child(2) { + width: 21px; +} + +.layout-side i:nth-child(3) { + width: 18px; +} + +.layout-main { + position: absolute; + inset: 9px 9px 9px 46px; + border-radius: var(--radius-md); + background: rgba(var(--primary-rgb), 0.12); +} + +.sidebar-choice.is-embedded .layout-side { + top: 0; + bottom: 0; + left: 0; + border-radius: 0; +} + +.sidebar-choice.is-edge .layout-side { + top: 0; + bottom: 0; + left: 0; + width: 40px; + border-radius: 0; + background: var(--primary); +} + +.sidebar-choice.is-edge .layout-main { + left: 40px; +} + +.dashboard-preview { + height: 58px; + border: 1px solid var(--line); +} + +.dashboard-preview i { + position: absolute; + display: block; + border-radius: var(--radius-md); + background: rgba(var(--primary-rgb), 0.24); +} + +.dashboard-preview i:first-child { + inset: 8px auto auto 8px; + width: 24px; + height: 42px; + background: rgba(var(--accent-rgb), 0.46); +} + +.dashboard-preview i:nth-child(2) { + top: 8px; + left: 40px; + right: 9px; + height: 6px; +} + +.dashboard-preview i:nth-child(3) { + top: 23px; + left: 40px; + width: 23px; + height: 6px; +} + +.dashboard-preview i:nth-child(4) { + left: 40px; + bottom: 9px; + width: 42px; + height: 21px; +} + +.dashboard-preview i:nth-child(5) { + right: 10px; + bottom: 12px; + width: 22px; + height: 22px; + border-radius: 999px; +} + +.dashboard-layout-choice.is-compact .dashboard-preview i:first-child { + width: 6px; +} + +.dashboard-layout-choice.is-compact .dashboard-preview i:nth-child(n + 2) { + left: 24px; +} + +.dashboard-layout-choice.is-fullscreen .dashboard-preview i:first-child { + display: none; +} + +.dashboard-layout-choice.is-fullscreen .dashboard-preview i:nth-child(n + 2) { + left: 9px; +} + +.content-width-choice { + min-height: 54px; +} + +.content-width-preview { + display: grid; + height: 27px; + align-content: center; + gap: 5px; + color: var(--muted); +} + +.content-width-preview i { + height: 4px; +} + +.content-width-choice.is-full .content-width-preview i { + width: 100%; +} + +.content-width-choice.is-full .content-width-preview i:nth-child(2) { + width: 92%; +} + +.content-width-choice.is-full .content-width-preview i:nth-child(3) { + width: 74%; +} + +.content-width-choice.is-centered .content-width-preview { + justify-items: center; +} + +.content-width-choice.is-centered .content-width-preview i:first-child { + width: 100%; +} + +.content-width-choice.is-centered .content-width-preview i:nth-child(2) { + width: 55%; +} + +.content-width-choice.is-centered .content-width-preview i:nth-child(3) { + width: 38%; +} + +.modal-backdrop { + position: fixed; + inset: 0; + z-index: 80; + display: grid; + align-items: start; + justify-items: center; + background: var(--modal-backdrop); + overflow-y: auto; + padding: 48px 24px; +} + +.profile-modal { + width: min(720px, 100%); + max-height: min(760px, calc(100vh - 48px)); + overflow: auto; + border: 1px solid var(--line); + border-radius: var(--radius-lg); + background: var(--card); + box-shadow: 0 26px 64px rgba(var(--primary-rgb), 0.22); +} + +.employee-modal { + width: min(980px, 100%); + max-height: min(780px, calc(100vh - 48px)); +} + +.modal-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + border-bottom: 1px solid var(--line-soft); + padding: 22px 24px; +} + +.modal-header h2 { + margin: 0; + color: var(--primary); + font-size: 22px; +} + +.modal-header p, +.profile-avatar-row p { + margin: 6px 0 0; + color: var(--muted); +} + +.profile-avatar-row { + display: flex; + align-items: center; + gap: 18px; + padding: 24px; +} + +.profile-avatar-row strong { + display: block; + color: var(--primary); + font-size: 18px; +} + +.avatar-upload { + position: relative; + margin-top: 14px; + overflow: hidden; +} + +.avatar-upload input { + position: absolute; + inset: 0; + opacity: 0; +} + +.profile-form { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; + border-top: 1px solid var(--line-soft); + padding: var(--panel-padding); +} + +.employee-modal-form { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.password-form { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 18px; + border-top: 1px solid var(--line-soft); + padding: var(--panel-padding); +} + +.form-section-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.form-section-head h3 { + margin: 0; + color: var(--primary); + font-size: 18px; +} + +.form-section-head p { + margin: 6px 0 0; + color: var(--muted); + line-height: 1.5; +} + +.view-stack { + display: grid; + gap: var(--content-gap); +} + +.page-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 20px; +} + +.page-header h1 { + margin: 0; + color: var(--primary); + font-size: 32px; + line-height: 1.2; +} + +.page-header p, +.panel-header p, +.action-card p { + margin: 8px 0 0; + color: var(--muted); + line-height: 1.6; +} + +.header-actions, +.form-actions { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.align-right { + justify-content: flex-end; +} + +.primary-button, +.secondary-button, +.link-button, +.file-button { + display: inline-flex; + min-height: var(--control-height); + align-items: center; + justify-content: center; + gap: 9px; + border-radius: var(--radius-md); + border: 1px solid transparent; + padding: 0 18px; + font-weight: 700; + transition: + background 0.18s ease, + border-color 0.18s ease, + box-shadow 0.18s ease, + transform 0.18s ease; +} + +.primary-button { + background: var(--blue); + color: white; + box-shadow: 0 12px 22px rgba(var(--accent-rgb), 0.16); +} + +.primary-button:hover { + background: var(--blue); + filter: brightness(0.94); +} + +.secondary-button { + border-color: var(--line); + background: var(--secondary-bg); + color: var(--primary); +} + +.secondary-button:hover { + border-color: var(--secondary-hover-border); + background: var(--secondary-hover-bg); +} + +.link-button { + min-height: auto; + border: 0; + background: transparent; + color: var(--blue); + padding: 0; + font-weight: 800; +} + +.full { + width: 100%; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: var(--content-gap); +} + +.stat-card, +.panel, +.action-card { + border: 1px solid var(--line); + border-radius: var(--radius-lg); + background: var(--card); + box-shadow: 0 8px 20px rgba(var(--primary-rgb), 0.04); +} + +.stat-card { + display: grid; + gap: 12px; + min-height: 178px; + padding: var(--panel-padding); +} + +.stat-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; +} + +.stat-icon, +.action-icon { + display: grid; + place-items: center; + border-radius: var(--radius-md); +} + +.stat-icon { + width: 52px; + height: 52px; + background: var(--surface-soft); + color: var(--primary); +} + +.tone-blue .stat-icon { + background: var(--blue-soft); + color: var(--blue); +} + +.tone-green .stat-icon { + background: var(--green-soft); + color: var(--green); +} + +.tone-red .stat-icon { + background: var(--red-soft); + color: var(--red); +} + +.stat-meta, +.status-chip { + display: inline-flex; + align-items: center; + min-height: 26px; + border-radius: var(--radius-sm); + padding: 0 10px; + font-size: 12px; + font-weight: 800; + white-space: nowrap; +} + +.stat-meta, +.status-chip.neutral { + background: var(--surface-soft); + color: var(--muted); +} + +.status-chip.green { + background: var(--green-soft); + color: var(--green); +} + +.status-chip.red { + background: var(--red-soft); + color: var(--red); +} + +.stat-card p { + margin: 0; + color: var(--muted); + font-size: 13px; + font-weight: 800; +} + +.stat-card strong { + color: var(--primary); + font-size: 28px; + line-height: 1.15; +} + +.employee-header-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; +} + +.employee-inline-metric { + display: flex; + min-height: 48px; + align-items: center; + gap: 10px; + border: 1px solid var(--line); + border-radius: var(--radius-md); + background: rgba(255, 255, 255, 0.58); + color: var(--primary); + padding: 8px 12px; +} + +.employee-inline-icon { + display: grid; + width: 34px; + height: 34px; + place-items: center; + border-radius: var(--radius-sm); + background: var(--blue-soft); + color: var(--blue); +} + +.employee-inline-metric div { + display: grid; + gap: 1px; +} + +.employee-inline-metric span { + color: var(--muted); + font-size: 12px; + font-weight: 800; +} + +.employee-inline-metric strong { + color: var(--primary); + font-size: 21px; + line-height: 1; +} + +.employee-inline-metric em { + border-radius: var(--radius-sm); + background: var(--surface-soft); + color: var(--muted); + padding: 4px 8px; + font-size: 12px; + font-style: normal; + font-weight: 800; + white-space: nowrap; +} + +.dashboard-grid, +.action-grid { + display: grid; + grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.6fr); + gap: var(--content-gap); +} + +.action-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.panel { + overflow: hidden; +} + +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + border-bottom: 1px solid var(--line-soft); + padding: calc(var(--panel-padding) - 2px) var(--panel-padding); +} + +.panel-header h2, +.action-card h2 { + margin: 0; + color: var(--primary); + font-size: 21px; + line-height: 1.3; +} + +.sync-panel, +.trend-panel { + min-height: 360px; +} + +.compact-list, +.detail-grid { + display: grid; + gap: 18px; + margin: 0; + padding: var(--panel-padding); +} + +.compact-list div, +.detail-grid div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + border-bottom: 1px solid var(--line-soft); + padding-bottom: 16px; +} + +.compact-list dt, +.detail-grid dt { + color: var(--muted); + font-size: 13px; +} + +.compact-list dd, +.detail-grid dd { + margin: 0; + color: var(--primary); + font-weight: 800; + text-align: right; +} + +.sync-panel .primary-button { + margin: 0 24px 24px; + width: calc(100% - 48px); +} + +.legend-line { + display: flex; + gap: 16px; + color: var(--muted); + font-size: 13px; +} + +.legend-line span { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.legend-line i { + display: inline-block; + width: 12px; + height: 12px; + border-radius: 999px; + background: var(--surface-strong); +} + +.legend-line .legend-blue { + background: var(--blue); +} + +.bar-chart { + display: grid; + height: 250px; + grid-template-columns: repeat(6, minmax(36px, 1fr)); + align-items: end; + gap: var(--content-gap); + padding: 24px 34px 28px; +} + +.bar-item { + position: relative; + display: grid; + height: 100%; + align-items: end; + justify-items: center; + gap: 10px; +} + +.bar-item strong, +.bonus-bar { + display: block; + width: 50px; + border-radius: var(--radius-sm) var(--radius-sm) 0 0; +} + +.bar-item strong { + background: var(--blue); +} + +.bonus-bar { + position: absolute; + bottom: 22px; + background: var(--surface-strong); +} + +.bar-item em { + font-style: normal; + color: var(--muted); + font-size: 13px; + font-weight: 700; +} + +.action-card { + position: relative; + display: grid; + align-content: start; + gap: 18px; + min-height: 260px; + padding: calc(var(--panel-padding) + 8px); +} + +.action-icon { + width: 62px; + height: 62px; +} + +.action-icon.blue { + background: var(--blue-soft); + color: var(--blue); +} + +.action-icon.green { + background: var(--green-soft); + color: var(--green); +} + +.file-button { + position: relative; + width: fit-content; + background: var(--blue); + color: white; +} + +.file-button input { + position: absolute; + inset: 0; + opacity: 0; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 18px; + padding: var(--panel-padding); +} + +.compact-form { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.user-form { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.span-row { + grid-column: 1 / -1; +} + +.field { + display: grid; + gap: 8px; +} + +.field span, +.checkbox-card span { + color: var(--muted); + font-size: 13px; + font-weight: 800; +} + +.field input, +.field select, +.field textarea, +.input-with-icon { + min-height: var(--control-height); + width: 100%; + border: 1px solid var(--line); + border-radius: var(--radius-md); + background: var(--control-bg); + color: var(--text); + outline: none; + transition: + border-color 0.18s ease, + box-shadow 0.18s ease; +} + +.field input, +.field select, +.field textarea { + padding: 0 13px; +} + +.field textarea { + min-height: 112px; + resize: vertical; + padding: 12px 13px; +} + +.field input:focus, +.field select:focus, +.field textarea:focus, +.input-with-icon:focus-within { + border-color: var(--blue); + box-shadow: 0 0 0 4px rgba(var(--accent-rgb), 0.12); +} + +.checkbox-card, +.checkbox-line { + display: flex; + align-items: center; + gap: 10px; +} + +.checkbox-card { + min-height: var(--control-height); + align-self: end; + border: 1px solid var(--line); + border-radius: var(--radius-md); + background: var(--control-bg); + padding: 0 14px; +} + +.form-actions { + border-top: 1px solid var(--line-soft); + padding: 18px 24px 24px; +} + +.search-form { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + gap: 16px; + padding: 24px; +} + +.table-filter-bar { + display: grid; + grid-template-columns: minmax(220px, 1fr) minmax(150px, 190px) auto; + align-items: end; + gap: 12px; + border-bottom: 1px solid var(--line-soft); + padding: 14px var(--panel-padding); +} + +.table-filter-bar .field { + gap: 6px; +} + +.table-filter-bar .field input, +.table-filter-bar .field select { + min-height: 40px; +} + +.table-pagination { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + border-top: 1px solid var(--line-soft); + padding: 18px 24px 24px; +} + +.table-pagination p { + margin: 0; + color: var(--muted); + font-weight: 700; +} + +.table-pagination .form-actions { + border-top: 0; + padding: 0; +} + +.compact-select, +.table-input, +.table-textarea { + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: var(--control-bg); + color: var(--text); + outline: 0; +} + +.compact-select { + min-height: 38px; + padding: 0 12px; +} + +.table-input { + min-height: 36px; + width: min(180px, 100%); + padding: 0 10px; +} + +.table-textarea { + min-height: 54px; + width: min(320px, 100%); + resize: vertical; + padding: 8px 10px; +} + +.table-wrap { + width: 100%; + overflow-x: auto; +} + +.data-table { + width: 100%; + min-width: 860px; + border-collapse: collapse; +} + +.data-table th, +.data-table td { + border-bottom: 1px solid var(--line-soft); + padding: var(--table-cell-y) var(--table-cell-x); + text-align: left; + vertical-align: middle; +} + +.data-table th { + background: var(--table-header-bg); + color: var(--muted); + font-size: 12px; + font-weight: 800; +} + +.data-table td { + color: var(--text); +} + +.data-table tr:hover td { + background: var(--table-row-hover-bg); +} + +.data-table td strong { + display: block; + color: var(--primary); +} + +.data-table td span { + display: block; + margin-top: 4px; + color: var(--muted); + font-size: 12px; +} + +.mono { + font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace; +} + +.strong { + font-weight: 800; +} + +.note-cell { + max-width: 260px; + color: var(--muted); +} + +.operation-log-table { + min-width: 1080px; +} + +.operation-log-table td span { + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.log-detail-cell { + max-width: 320px; + overflow: hidden; + color: var(--muted); + text-overflow: ellipsis; + white-space: nowrap; +} + +.empty-state { + display: grid; + min-height: 150px; + place-items: center; + color: var(--muted); + padding: 24px; +} + +.recent-list { + display: grid; + gap: 10px; + padding: 18px 24px 8px; +} + +.recent-item { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 14px; + width: 100%; + border: 1px solid var(--line-soft); + border-radius: var(--radius-md); + background: var(--control-bg); + color: var(--text); + padding: 14px; + text-align: left; +} + +.recent-item:hover { + border-color: var(--blue); +} + +.recent-item span, +.recent-item em { + display: block; +} + +.recent-item strong { + color: var(--primary); +} + +.recent-item em, +.recent-item small { + color: var(--muted); + font-style: normal; +} + +.form-error, +.form-success { + margin: 0; + border-radius: var(--radius-md); + padding: 12px 14px; + font-weight: 700; +} + +.form-error { + background: var(--red-soft); + color: var(--red); +} + +.form-success { + background: var(--green-soft); + color: var(--green); +} + +.spin { + animation: spin 0.9s linear infinite; +} + +.login-screen { + display: grid; + min-height: 100vh; + grid-template-columns: minmax(420px, 42vw) minmax(0, 1fr); + background: var(--surface); +} + +.login-form-side { + display: grid; + place-items: center; + padding: 48px; +} + +.login-card { + width: min(440px, 100%); +} + +.login-brand { + margin-bottom: 28px; +} + +.login-brand h1 { + margin: 22px 0 8px; + color: var(--primary); + font-size: 28px; + line-height: 1.2; +} + +.login-brand p { + margin: 0; + color: var(--muted); +} + +.form-stack { + display: grid; + gap: 18px; +} + +.input-with-icon { + display: flex; + align-items: center; + gap: 12px; + padding: 0 13px; +} + +.input-with-icon input { + min-height: var(--control-height); + flex: 1; + border: 0; + background: transparent; + outline: 0; +} + +.input-action { + display: grid; + width: 34px; + height: 34px; + place-items: center; + border: 0; + border-radius: 999px; + background: transparent; + color: var(--muted); +} + +.input-action:hover { + background: var(--surface-soft); +} + +.login-footer { + display: flex; + flex-wrap: wrap; + gap: 14px 22px; + margin-top: 42px; + border-top: 1px solid var(--line); + padding-top: 24px; + color: var(--muted); +} + +.login-footer p { + flex-basis: 100%; + margin: 0; +} + +.login-footer span { + color: var(--primary); + font-weight: 700; +} + +.login-visual { + position: relative; + display: grid; + place-items: center; + overflow: hidden; + background: + linear-gradient(135deg, rgba(var(--primary-rgb), 0.94), rgba(var(--accent-rgb), 0.86)), + repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.08) 0 1px, transparent 1px 74px), + repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.05) 0 1px, transparent 1px 74px); + color: white; + padding: 48px; +} + +.ledger-visual { + width: min(620px, 100%); + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: var(--radius-lg); + background: rgba(255, 255, 255, 0.07); + padding: 48px; + box-shadow: 0 28px 60px rgba(0, 0, 0, 0.24); +} + +.ledger-visual h2 { + margin: 24px 0 18px; + font-size: 44px; + line-height: 1.18; +} + +.ledger-visual p { + margin: 0; + color: rgba(255, 255, 255, 0.82); + font-size: 17px; + line-height: 1.7; +} + +.visual-metrics { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--content-gap); + margin-top: 34px; + border-top: 1px solid rgba(255, 255, 255, 0.16); + padding-top: 28px; +} + +.visual-metrics strong, +.visual-metrics span { + display: block; +} + +.visual-metrics strong { + font-size: 32px; +} + +.visual-metrics span { + margin-top: 4px; + color: rgba(255, 255, 255, 0.68); +} + +.security-line { + position: absolute; + right: 32px; + bottom: 28px; + display: inline-flex; + align-items: center; + gap: 8px; + color: rgba(255, 255, 255, 0.58); + font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace; + font-size: 13px; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@media (max-width: 1120px) { + .appearance-panel { + width: min(540px, calc(100vw - 16px)); + padding: 14px; + } + + .color-preset-grid, + .density-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .radius-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .stats-grid, + .dashboard-grid, + .action-grid, + .form-grid, + .password-form { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .login-screen { + grid-template-columns: 1fr; + } + + .login-visual { + display: none; + } +} + +@media (max-width: 820px) { + .main-shell { + margin-left: 0; + } + + .sidebar { + transform: translateX(-100%); + } + + .sidebar.is-open { + transform: translateX(0); + } + + .sidebar-scrim { + position: fixed; + inset: 0; + z-index: 25; + background: var(--modal-backdrop); + } + + .mobile-menu { + display: grid; + } + + .topbar { + padding: 0 18px; + } + + .global-search { + width: min(100%, 420px); + min-width: 0; + } + + .user-text { + display: none; + } + + .page-shell { + padding: 22px; + } + + .page-header { + align-items: stretch; + flex-direction: column; + } + + .mode-grid, + .font-grid, + .sidebar-grid, + .layout-grid, + .content-width-grid { + grid-template-columns: 1fr; + } + + .header-actions, + .form-actions { + width: 100%; + } + + .employee-header-actions { + justify-content: flex-start; + flex-wrap: wrap; + } + + .employee-inline-metric { + flex: 0 1 auto; + } + + .table-pagination { + align-items: stretch; + flex-direction: column; + } + + .header-actions > *, + .form-actions > * { + flex: 1 1 auto; + } +} + +@media (max-width: 640px) { + .appearance-panel { + width: 100vw; + max-height: 100vh; + border-radius: 0; + padding: 16px; + } + + .appearance-panel-header h2 { + font-size: 17px; + } + + .appearance-section-head h3 { + font-size: 15px; + } + + .color-preset-grid, + .radius-grid, + .density-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .stats-grid, + .dashboard-grid, + .action-grid, + .form-grid, + .user-form, + .password-form, + .search-form, + .table-filter-bar, + .visual-metrics { + grid-template-columns: 1fr; + } + + .employee-header-actions { + align-items: stretch; + flex-direction: column; + } + + .employee-inline-metric { + justify-content: flex-start; + } + + .login-form-side { + padding: 28px 20px; + } + + .topbar-actions { + gap: 8px; + } + + .global-search { + display: none; + } + + .page-header h1 { + font-size: 27px; + } +} + +@media (prefers-reduced-motion: reduce) { + :root { + --motion-duration: 1ms; + --motion-duration-fast: 1ms; + } +} diff --git a/frontend/src/components/AppLayout.vue b/frontend/src/components/AppLayout.vue new file mode 100644 index 0000000..262114a --- /dev/null +++ b/frontend/src/components/AppLayout.vue @@ -0,0 +1,40 @@ + + + diff --git a/frontend/src/components/AppearancePanel.vue b/frontend/src/components/AppearancePanel.vue new file mode 100644 index 0000000..acedbe0 --- /dev/null +++ b/frontend/src/components/AppearancePanel.vue @@ -0,0 +1,316 @@ + + + diff --git a/frontend/src/components/ResultsTable.vue b/frontend/src/components/ResultsTable.vue new file mode 100644 index 0000000..9c9d821 --- /dev/null +++ b/frontend/src/components/ResultsTable.vue @@ -0,0 +1,113 @@ + + + diff --git a/frontend/src/components/SidebarNav.vue b/frontend/src/components/SidebarNav.vue new file mode 100644 index 0000000..6f3cc68 --- /dev/null +++ b/frontend/src/components/SidebarNav.vue @@ -0,0 +1,116 @@ + + + diff --git a/frontend/src/components/StatCard.vue b/frontend/src/components/StatCard.vue new file mode 100644 index 0000000..94737a4 --- /dev/null +++ b/frontend/src/components/StatCard.vue @@ -0,0 +1,24 @@ + + + diff --git a/frontend/src/components/TopBar.vue b/frontend/src/components/TopBar.vue new file mode 100644 index 0000000..2397a5e --- /dev/null +++ b/frontend/src/components/TopBar.vue @@ -0,0 +1,292 @@ + + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..ef044a1 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,14 @@ +import { createPinia } from "pinia"; +import { createApp } from "vue"; + +import App from "./App.vue"; +import router from "./router"; +import { useThemeStore } from "./stores/theme"; +import "./assets/styles.css"; + +const pinia = createPinia(); +const app = createApp(App); + +app.use(pinia); +useThemeStore().applyTheme(); +app.use(router).mount("#app"); diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts new file mode 100644 index 0000000..df270a5 --- /dev/null +++ b/frontend/src/router/index.ts @@ -0,0 +1,173 @@ +import { createRouter, createWebHistory } from "vue-router"; + +import AppLayout from "../components/AppLayout.vue"; +import { useAuthStore } from "../stores/auth"; +import CommissionsView from "../views/CommissionsView.vue"; +import ConfigCenterView from "../views/ConfigCenterView.vue"; +import DashboardView from "../views/DashboardView.vue"; +import EmployeesView from "../views/EmployeesView.vue"; +import JobsView from "../views/JobsView.vue"; +import LoginView from "../views/LoginView.vue"; +import OperationLogsView from "../views/OperationLogsView.vue"; +import OrganizationView from "../views/OrganizationView.vue"; +import PayrollView from "../views/PayrollView.vue"; +import ReportsView from "../views/ReportsView.vue"; +import SalaryProfilesView from "../views/SalaryProfilesView.vue"; +import UsersView from "../views/UsersView.vue"; + +declare module "vue-router" { + interface RouteMeta { + public?: boolean; + permission?: string; + } +} + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { + path: "/login", + name: "login", + component: LoginView, + meta: { public: true }, + }, + { + path: "/", + component: AppLayout, + children: [ + { + path: "", + redirect: "/dashboard", + }, + { + path: "dashboard", + name: "dashboard", + component: DashboardView, + }, + { + path: "payroll", + name: "payroll", + component: PayrollView, + meta: { permission: "payroll:excel:calculate" }, + }, + { + path: "payroll/jobs", + name: "jobs", + component: JobsView, + meta: { permission: "payroll:job:view" }, + }, + { + path: "payroll/commissions", + name: "commissions", + component: CommissionsView, + meta: { permission: "commission:view" }, + }, + { + path: "reports", + name: "reports", + component: ReportsView, + meta: { permission: "report:view" }, + }, + { + path: "system/users", + name: "users", + component: UsersView, + meta: { permission: "user:list" }, + }, + { + path: "system/employees", + name: "employees", + component: EmployeesView, + meta: { permission: "employee:view" }, + }, + { + path: "system/organization", + name: "organization", + component: OrganizationView, + meta: { permission: "organization:view" }, + }, + { + path: "system/salary-profiles", + name: "salary-profiles", + component: SalaryProfilesView, + meta: { permission: "salary_profile:view" }, + }, + { + path: "system/configs", + name: "configs", + component: ConfigCenterView, + meta: { permission: "config:view" }, + }, + { + path: "system/operation-logs", + name: "operation-logs", + component: OperationLogsView, + meta: { permission: "operation_log:view" }, + }, + ], + }, + { + path: "/:pathMatch(.*)*", + redirect: "/dashboard", + }, + ], +}); + +router.beforeEach(async (to) => { + const auth = useAuthStore(); + + if (!to.meta.public && !auth.loaded) { + await auth.loadSession(); + } + + if (to.meta.public) { + return auth.isAuthenticated ? "/dashboard" : true; + } + + if (!auth.isAuthenticated) { + return { path: "/login", query: { redirect: to.fullPath } }; + } + + const permission = to.meta.permission; + if (permission && !auth.hasPermission(permission)) { + return firstAllowedPath(auth.permissions); + } + + return true; +}); + +function firstAllowedPath(permissions: string[]): string { + if (permissions.includes("payroll:excel:calculate")) { + return "/payroll"; + } + if (permissions.includes("payroll:job:view")) { + return "/payroll/jobs"; + } + if (permissions.includes("commission:view")) { + return "/payroll/commissions"; + } + if (permissions.includes("report:view")) { + return "/reports"; + } + if (permissions.includes("user:list")) { + return "/system/users"; + } + if (permissions.includes("employee:view")) { + return "/system/employees"; + } + if (permissions.includes("organization:view")) { + return "/system/organization"; + } + if (permissions.includes("salary_profile:view")) { + return "/system/salary-profiles"; + } + if (permissions.includes("config:view")) { + return "/system/configs"; + } + if (permissions.includes("operation_log:view")) { + return "/system/operation-logs"; + } + return "/dashboard"; +} + +export default router; diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts new file mode 100644 index 0000000..07b87d5 --- /dev/null +++ b/frontend/src/stores/auth.ts @@ -0,0 +1,102 @@ +import { defineStore } from "pinia"; + +import { + clearStoredToken, + getStoredToken, + setStoredToken, +} from "../api/http"; +import { + changePassword as changePasswordRequest, + getCurrentUser, + login as loginRequest, + updateProfile as updateProfileRequest, + uploadAvatar as uploadAvatarRequest, +} from "../api/auth"; +import type { ChangePasswordRequest, MenuItem, UpdateProfileRequest, UserProfile } from "../api/types"; + +interface AuthState { + token: string; + user: UserProfile | null; + menus: MenuItem[]; + permissions: string[]; + loaded: boolean; +} + +export const useAuthStore = defineStore("auth", { + state: (): AuthState => ({ + token: getStoredToken(), + user: null, + menus: [], + permissions: [], + loaded: false, + }), + getters: { + isAuthenticated: (state) => Boolean(state.token), + displayName: (state) => state.user?.display_name || state.user?.username || "未登录", + roleLabel: (state) => roleLabel(state.user?.role || ""), + }, + actions: { + hasPermission(permission: string): boolean { + return this.permissions.includes(permission); + }, + async login(username: string, password: string): Promise { + const session = await loginRequest(username, password); + this.token = session.access_token; + this.user = session.user; + this.menus = session.menus; + this.permissions = session.permissions; + this.loaded = true; + setStoredToken(session.access_token); + }, + async loadSession(): Promise { + if (!this.token) { + this.loaded = true; + return; + } + try { + const profile = await getCurrentUser(); + this.user = profile; + this.menus = profile.menus; + this.permissions = profile.permissions; + } catch (error) { + this.logout(); + throw error; + } finally { + this.loaded = true; + } + }, + async updateProfile(payload: UpdateProfileRequest): Promise { + const profile = await updateProfileRequest(payload); + this.user = profile; + this.menus = profile.menus; + this.permissions = profile.permissions; + }, + async uploadAvatar(file: File): Promise { + const profile = await uploadAvatarRequest(file); + this.user = profile; + this.menus = profile.menus; + this.permissions = profile.permissions; + }, + async changePassword(payload: ChangePasswordRequest): Promise { + const response = await changePasswordRequest(payload); + return response.message; + }, + logout(): void { + this.token = ""; + this.user = null; + this.menus = []; + this.permissions = []; + this.loaded = true; + clearStoredToken(); + }, + }, +}); + +function roleLabel(role: string): string { + const labels: Record = { + superuser: "超级用户", + manager: "管理者", + viewer: "查看权限", + }; + return labels[role] || "未授权"; +} diff --git a/frontend/src/stores/theme.ts b/frontend/src/stores/theme.ts new file mode 100644 index 0000000..bbb0a6c --- /dev/null +++ b/frontend/src/stores/theme.ts @@ -0,0 +1,843 @@ +import { defineStore } from "pinia"; + +export type ThemeName = + | "default" + | "anthropic" + | "mono" + | "midnight" + | "rose" + | "lagoon" + | "sunset" + | "forest" + | "ocean" + | "lavender"; +export type ThemeMode = "system" | "light" | "dark"; +export type FontChoice = "auto" | "sans" | "serif"; +export type RadiusChoice = "auto" | "0" | "0.3" | "0.5" | "0.75" | "1"; +export type DensityChoice = "compact" | "default" | "comfortable" | "large"; +export type SidebarChoice = "embedded" | "floating" | "edge"; +export type LayoutChoice = "default" | "compact" | "fullscreen"; +export type ContentWidthChoice = "full" | "centered"; + +export interface ThemeOption { + name: ThemeName; + label: string; + primary: string; + accent: string; + soft: string; + gradient: [string, string]; + tokens: { + light: ThemeTokens; + dark: ThemeTokens; + }; +} + +interface ThemeTokens { + surface: string; + surfaceSoft: string; + surfaceStrong: string; + card: string; + text: string; + muted: string; + line: string; + lineSoft: string; + primary: string; + primarySoft: string; + accent: string; + accentSoft: string; + sidebarBg: string; + navText: string; + navActiveBg: string; + topbarBg: string; + searchBg: string; + controlBg: string; + secondaryBg: string; + secondaryHoverBg: string; + secondaryHoverBorder: string; + tableHeaderBg: string; + tableRowHoverBg: string; +} + +type LightThemeTokens = Omit; +type DarkThemeSeed = Omit< + ThemeTokens, + | "text" + | "muted" + | "navText" + | "topbarBg" + | "searchBg" + | "controlBg" + | "secondaryBg" + | "secondaryHoverBg" + | "secondaryHoverBorder" + | "tableHeaderBg" + | "tableRowHoverBg" +>; + +interface ThemeSeed { + name: ThemeName; + label: string; + primary: string; + accent: string; + soft: string; + gradient: [string, string]; + light: LightThemeTokens; + dark: ThemeTokens; +} + +type StoredSetting = + | ThemeName + | ThemeMode + | FontChoice + | RadiusChoice + | DensityChoice + | SidebarChoice + | LayoutChoice + | ContentWidthChoice; + +const THEME_KEY = "financial-system-theme"; +const THEME_MODE_KEY = "financial-system-theme-mode"; +const FONT_KEY = "financial-system-font"; +const RADIUS_KEY = "financial-system-radius"; +const DENSITY_KEY = "financial-system-density"; +const SIDEBAR_KEY = "financial-system-sidebar"; +const LAYOUT_KEY = "financial-system-layout"; +const CONTENT_WIDTH_KEY = "financial-system-content-width"; + +const DEFAULTS = { + theme: "anthropic" as ThemeName, + mode: "dark" as ThemeMode, + font: "serif" as FontChoice, + radius: "1" as RadiusChoice, + density: "compact" as DensityChoice, + sidebar: "floating" as SidebarChoice, + layout: "default" as LayoutChoice, + contentWidth: "full" as ContentWidthChoice, +}; + +const legacyThemeMap: Record = { + navy: "anthropic", + blue: "ocean", + emerald: "forest", + burgundy: "rose", +}; + +const themeSeeds: ThemeSeed[] = [ + { + name: "default", + label: "默认", + primary: "#171717", + accent: "#8c8c8c", + soft: "#ececec", + gradient: ["#151515", "#f4f4f1"], + light: createLightSeed({ + primary: "#171717", + accent: "#626262", + soft: "#ececec", + surface: "#f7f7f5", + surfaceSoft: "#eeeeeb", + surfaceStrong: "#dfdfdc", + sidebarBg: "#eeeeeb", + navActiveBg: "#e1e1dd", + }), + dark: createDarkSeed({ + primary: "#f4f1eb", + primarySoft: "#ded8cf", + accent: "#b9b6ad", + accentSoft: "rgba(185, 182, 173, 0.18)", + surface: "#141411", + surfaceSoft: "#1d1c18", + surfaceStrong: "#2b2925", + card: "#191814", + sidebarBg: "#1a1915", + navActiveBg: "#28251f", + line: "#34312a", + lineSoft: "#282620", + }), + }, + { + name: "anthropic", + label: "Anthropic", + primary: "#8f563e", + accent: "#ed8b6d", + soft: "#fde4d8", + gradient: ["#fbe2d4", "#ea7b5d"], + light: createLightSeed({ + primary: "#6f3e2a", + accent: "#dd7154", + soft: "#fde4d8", + surface: "#fff8f4", + surfaceSoft: "#f6e7df", + surfaceStrong: "#edcdbf", + sidebarBg: "#f6e7df", + navActiveBg: "#f1d3c5", + }), + dark: createDarkSeed({ + primary: "#f4dfd3", + primarySoft: "#e7b7a4", + accent: "#f28a6a", + accentSoft: "rgba(242, 138, 106, 0.18)", + surface: "#16130f", + surfaceSoft: "#211913", + surfaceStrong: "#392417", + card: "#1b1712", + sidebarBg: "#201713", + navActiveBg: "#5b3425", + line: "#403029", + lineSoft: "#2b211c", + }), + }, + { + name: "mono", + label: "超大字体简易", + primary: "#202020", + accent: "#a1a1a1", + soft: "#eeeeee", + gradient: ["#202020", "#eeeeee"], + light: createLightSeed({ + primary: "#202020", + accent: "#6d6d6d", + soft: "#eeeeee", + surface: "#fafafa", + surfaceSoft: "#eeeeee", + surfaceStrong: "#dcdcdc", + sidebarBg: "#eeeeee", + navActiveBg: "#e2e2e2", + }), + dark: createDarkSeed({ + primary: "#f5f5f5", + primarySoft: "#dddddd", + accent: "#c8c8c8", + accentSoft: "rgba(200, 200, 200, 0.16)", + surface: "#121212", + surfaceSoft: "#1f1f1f", + surfaceStrong: "#303030", + card: "#181818", + sidebarBg: "#1b1b1b", + navActiveBg: "#2b2b2b", + line: "#3a3a3a", + lineSoft: "#2a2a2a", + }), + }, + { + name: "midnight", + label: "暗夜", + primary: "#1d3150", + accent: "#2d5da8", + soft: "#e6efff", + gradient: ["#466f5a", "#9b7390"], + light: createLightSeed({ + primary: "#1d3150", + accent: "#2d5da8", + soft: "#e6efff", + surface: "#f5f8fc", + surfaceSoft: "#e8eef6", + surfaceStrong: "#d3deed", + sidebarBg: "#e6edf6", + navActiveBg: "#d5e1f2", + }), + dark: createDarkSeed({ + primary: "#dce8ff", + primarySoft: "#a8c5ef", + accent: "#6ca1ee", + accentSoft: "rgba(108, 161, 238, 0.18)", + surface: "#10151e", + surfaceSoft: "#162235", + surfaceStrong: "#213451", + card: "#131b28", + sidebarBg: "#17263a", + navActiveBg: "#1d3b64", + line: "#2c3d55", + lineSoft: "#213047", + }), + }, + { + name: "rose", + label: "玫瑰花园", + primary: "#8f2044", + accent: "#f13d72", + soft: "#ffe2ea", + gradient: ["#f22366", "#f77c99"], + light: createLightSeed({ + primary: "#8f2044", + accent: "#e53967", + soft: "#ffe2ea", + surface: "#fff7fa", + surfaceSoft: "#fde9f0", + surfaceStrong: "#f6ccd9", + sidebarBg: "#fde9f0", + navActiveBg: "#f8d4df", + }), + dark: createDarkSeed({ + primary: "#ffdce7", + primarySoft: "#f7a5be", + accent: "#ff6d97", + accentSoft: "rgba(255, 109, 151, 0.18)", + surface: "#171116", + surfaceSoft: "#26141c", + surfaceStrong: "#471d2d", + card: "#1d1218", + sidebarBg: "#25131b", + navActiveBg: "#5a2136", + line: "#442934", + lineSoft: "#2f1d25", + }), + }, + { + name: "lagoon", + label: "湖光", + primary: "#006b5e", + accent: "#08b997", + soft: "#dcfbf4", + gradient: ["#0fc69b", "#058b88"], + light: createLightSeed({ + primary: "#006b5e", + accent: "#08a88c", + soft: "#dcfbf4", + surface: "#f4fbf9", + surfaceSoft: "#e0f4ef", + surfaceStrong: "#c4e7de", + sidebarBg: "#e0f4ef", + navActiveBg: "#caeadf", + }), + dark: createDarkSeed({ + primary: "#d8fff6", + primarySoft: "#86e4d0", + accent: "#20d0ad", + accentSoft: "rgba(32, 208, 173, 0.18)", + surface: "#0f1715", + surfaceSoft: "#112923", + surfaceStrong: "#14483d", + card: "#101d1a", + sidebarBg: "#132820", + navActiveBg: "#195745", + line: "#27433b", + lineSoft: "#1c312b", + }), + }, + { + name: "sunset", + label: "日落霞光", + primary: "#b93f32", + accent: "#f2765a", + soft: "#ffe4d9", + gradient: ["#df443f", "#fb805e"], + light: createLightSeed({ + primary: "#9f392e", + accent: "#ea674c", + soft: "#ffe4d9", + surface: "#fff8f4", + surfaceSoft: "#f7e8df", + surfaceStrong: "#f0ccb9", + sidebarBg: "#f7e8df", + navActiveBg: "#f4d4c3", + }), + dark: createDarkSeed({ + primary: "#ffe1d5", + primarySoft: "#f4a089", + accent: "#ff8a68", + accentSoft: "rgba(255, 138, 104, 0.18)", + surface: "#171210", + surfaceSoft: "#281914", + surfaceStrong: "#52271d", + card: "#1d1512", + sidebarBg: "#271814", + navActiveBg: "#653123", + line: "#442d25", + lineSoft: "#2f201b", + }), + }, + { + name: "forest", + label: "森林低语", + primary: "#1d6253", + accent: "#4f7c86", + soft: "#e0f2ed", + gradient: ["#1b8d7e", "#51788c"], + light: createLightSeed({ + primary: "#1d6253", + accent: "#3f7881", + soft: "#e0f2ed", + surface: "#f4faf7", + surfaceSoft: "#e4f1ec", + surfaceStrong: "#cfe2dc", + sidebarBg: "#e4f1ec", + navActiveBg: "#d4e9e2", + }), + dark: createDarkSeed({ + primary: "#dbfff2", + primarySoft: "#9dd6c7", + accent: "#78aeb9", + accentSoft: "rgba(120, 174, 185, 0.18)", + surface: "#111817", + surfaceSoft: "#172623", + surfaceStrong: "#243f3b", + card: "#141d1c", + sidebarBg: "#172521", + navActiveBg: "#254d45", + line: "#2d4440", + lineSoft: "#22322f", + }), + }, + { + name: "ocean", + label: "海风", + primary: "#2148d8", + accent: "#4964f2", + soft: "#e7ecff", + gradient: ["#315fe8", "#6464f3"], + light: createLightSeed({ + primary: "#2148d8", + accent: "#4964f2", + soft: "#e7ecff", + surface: "#f6f8ff", + surfaceSoft: "#e9eeff", + surfaceStrong: "#d6ddfb", + sidebarBg: "#e9eeff", + navActiveBg: "#d9e0ff", + }), + dark: createDarkSeed({ + primary: "#e0e6ff", + primarySoft: "#aebcff", + accent: "#7c92ff", + accentSoft: "rgba(124, 146, 255, 0.18)", + surface: "#11131d", + surfaceSoft: "#181d35", + surfaceStrong: "#24336c", + card: "#151827", + sidebarBg: "#171d34", + navActiveBg: "#25387c", + line: "#2c365d", + lineSoft: "#222944", + }), + }, + { + name: "lavender", + label: "薰衣草梦", + primary: "#7f58c2", + accent: "#8aaec4", + soft: "#ece5ff", + gradient: ["#8e63ca", "#9cc8d4"], + light: createLightSeed({ + primary: "#7653b7", + accent: "#7fa9bc", + soft: "#ece5ff", + surface: "#faf8ff", + surfaceSoft: "#eee8fa", + surfaceStrong: "#ddd5ee", + sidebarBg: "#eee8fa", + navActiveBg: "#e1d7f2", + }), + dark: createDarkSeed({ + primary: "#eee6ff", + primarySoft: "#c4a8ef", + accent: "#a8cfdd", + accentSoft: "rgba(168, 207, 221, 0.18)", + surface: "#15131b", + surfaceSoft: "#201a2c", + surfaceStrong: "#382a55", + card: "#1a1622", + sidebarBg: "#201a2a", + navActiveBg: "#49366f", + line: "#3c334c", + lineSoft: "#2b2537", + }), + }, +]; + +export const themeOptions: ThemeOption[] = themeSeeds.map((seed) => ({ + name: seed.name, + label: seed.label, + primary: seed.primary, + accent: seed.accent, + soft: seed.soft, + gradient: seed.gradient, + tokens: { + light: { + ...seed.light, + primary: seed.primary, + primarySoft: mix(seed.primary, "#ffffff", 18), + accent: seed.accent, + accentSoft: seed.soft, + }, + dark: seed.dark, + }, +})); + +export const useThemeStore = defineStore("theme", { + state: () => ({ + current: readThemeName(), + mode: readSetting(THEME_MODE_KEY, DEFAULTS.mode, ["system", "light", "dark"] as const), + font: readSetting(FONT_KEY, DEFAULTS.font, ["auto", "sans", "serif"] as const), + radius: readSetting(RADIUS_KEY, DEFAULTS.radius, ["auto", "0", "0.3", "0.5", "0.75", "1"] as const), + density: readSetting(DENSITY_KEY, DEFAULTS.density, ["compact", "default", "comfortable", "large"] as const), + sidebar: readSetting(SIDEBAR_KEY, DEFAULTS.sidebar, ["embedded", "floating", "edge"] as const), + layout: readSetting(LAYOUT_KEY, DEFAULTS.layout, ["default", "compact", "fullscreen"] as const), + contentWidth: readSetting(CONTENT_WIDTH_KEY, DEFAULTS.contentWidth, ["full", "centered"] as const), + }), + getters: { + activeTheme: (state) => themeOptions.find((item) => item.name === state.current) || themeOptions[0], + resolvedMode: (state): "light" | "dark" => resolveThemeMode(state.mode), + }, + actions: { + applyTheme(themeName?: ThemeName): void { + if (themeName) { + this.current = themeName; + } + + const theme = themeOptions.find((item) => item.name === this.current) || themeOptions[0]; + const mode = resolveThemeMode(this.mode); + const tokens = theme.tokens[mode]; + const root = document.documentElement; + const cssVariables: Record = { + "--surface": tokens.surface, + "--surface-soft": tokens.surfaceSoft, + "--surface-strong": tokens.surfaceStrong, + "--card": tokens.card, + "--text": tokens.text, + "--muted": tokens.muted, + "--line": tokens.line, + "--line-soft": tokens.lineSoft, + "--primary": tokens.primary, + "--primary-soft": tokens.primarySoft, + "--blue": tokens.accent, + "--blue-soft": tokens.accentSoft, + "--sidebar-bg": tokens.sidebarBg, + "--nav-text": tokens.navText, + "--nav-active-bg": tokens.navActiveBg, + "--topbar-bg": tokens.topbarBg, + "--search-bg": tokens.searchBg, + "--control-bg": tokens.controlBg, + "--secondary-bg": tokens.secondaryBg, + "--secondary-hover-bg": tokens.secondaryHoverBg, + "--secondary-hover-border": tokens.secondaryHoverBorder, + "--table-header-bg": tokens.tableHeaderBg, + "--table-row-hover-bg": tokens.tableRowHoverBg, + "--modal-backdrop": hexToRgba(mode === "dark" ? "#000000" : theme.primary, mode === "dark" ? 0.56 : 0.32), + "--primary-rgb": hexToRgbCsv(mode === "dark" ? theme.primary : tokens.primary), + "--accent-rgb": hexToRgbCsv(tokens.accent), + "--shadow": mode === "dark" ? "0 20px 44px rgba(0, 0, 0, 0.34)" : `0 16px 32px ${hexToRgba(theme.primary, 0.08)}`, + ...fontVariables(this.font), + ...radiusVariables(this.radius), + ...densityVariables(this.density), + ...layoutVariables(this.layout, this.sidebar, this.contentWidth), + }; + + Object.entries(cssVariables).forEach(([name, value]) => { + root.style.setProperty(name, value); + }); + + root.dataset.themeMode = mode; + root.dataset.themePreset = theme.name; + root.dataset.font = this.font; + root.dataset.radius = this.radius; + root.dataset.density = this.density; + root.dataset.sidebar = this.sidebar; + root.dataset.layout = this.layout; + root.dataset.contentWidth = this.contentWidth; + + persistSetting(THEME_KEY, theme.name); + persistSetting(THEME_MODE_KEY, this.mode); + persistSetting(FONT_KEY, this.font); + persistSetting(RADIUS_KEY, this.radius); + persistSetting(DENSITY_KEY, this.density); + persistSetting(SIDEBAR_KEY, this.sidebar); + persistSetting(LAYOUT_KEY, this.layout); + persistSetting(CONTENT_WIDTH_KEY, this.contentWidth); + watchSystemTheme(() => this.applyTheme()); + }, + setTheme(themeName: ThemeName): void { + this.current = themeName; + this.applyTheme(); + }, + setMode(mode: ThemeMode): void { + this.mode = mode; + this.applyTheme(); + }, + setFont(font: FontChoice): void { + this.font = font; + this.applyTheme(); + }, + setRadius(radius: RadiusChoice): void { + this.radius = radius; + this.applyTheme(); + }, + setDensity(density: DensityChoice): void { + this.density = density; + this.applyTheme(); + }, + setSidebar(sidebar: SidebarChoice): void { + this.sidebar = sidebar; + this.applyTheme(); + }, + setLayout(layout: LayoutChoice): void { + this.layout = layout; + this.applyTheme(); + }, + setContentWidth(contentWidth: ContentWidthChoice): void { + this.contentWidth = contentWidth; + this.applyTheme(); + }, + resetTheme(): void { + this.current = DEFAULTS.theme; + this.mode = DEFAULTS.mode; + this.applyTheme(); + }, + resetColor(): void { + this.current = DEFAULTS.theme; + this.applyTheme(); + }, + resetFont(): void { + this.font = DEFAULTS.font; + this.applyTheme(); + }, + resetRadius(): void { + this.radius = DEFAULTS.radius; + this.applyTheme(); + }, + resetDensity(): void { + this.density = DEFAULTS.density; + this.applyTheme(); + }, + resetSidebar(): void { + this.sidebar = DEFAULTS.sidebar; + this.applyTheme(); + }, + resetLayout(): void { + this.layout = DEFAULTS.layout; + this.applyTheme(); + }, + resetContentWidth(): void { + this.contentWidth = DEFAULTS.contentWidth; + this.applyTheme(); + }, + }, +}); + +function createLightSeed(seed: { + primary: string; + accent: string; + soft: string; + surface: string; + surfaceSoft: string; + surfaceStrong: string; + sidebarBg: string; + navActiveBg: string; +}): LightThemeTokens { + return { + surface: seed.surface, + surfaceSoft: seed.surfaceSoft, + surfaceStrong: seed.surfaceStrong, + card: "#ffffff", + text: "#191c1e", + muted: "#5f6670", + line: mix(seed.surfaceStrong, "#88919c", 26), + lineSoft: mix(seed.surfaceSoft, "#ffffff", 54), + sidebarBg: seed.sidebarBg, + navText: "#303842", + navActiveBg: seed.navActiveBg, + topbarBg: hexToRgba(seed.surface, 0.94), + searchBg: mix(seed.surfaceSoft, "#ffffff", 28), + controlBg: "#ffffff", + secondaryBg: seed.surfaceSoft, + secondaryHoverBg: seed.surfaceStrong, + secondaryHoverBorder: mix(seed.surfaceStrong, "#87919c", 34), + tableHeaderBg: mix(seed.surfaceSoft, "#ffffff", 34), + tableRowHoverBg: mix(seed.surface, "#ffffff", 38), + }; +} + +function createDarkSeed(seed: DarkThemeSeed): ThemeTokens { + return { + ...seed, + text: "#f4f0e8", + muted: "#b8b2a8", + navText: "#d6cec4", + topbarBg: hexToRgba(seed.surface, 0.88), + searchBg: seed.surfaceSoft, + controlBg: seed.surfaceSoft, + secondaryBg: seed.surfaceStrong, + secondaryHoverBg: seed.navActiveBg, + secondaryHoverBorder: seed.line, + tableHeaderBg: seed.surfaceSoft, + tableRowHoverBg: seed.surfaceStrong, + }; +} + +function fontVariables(font: FontChoice): Record { + const fontMap: Record = { + auto: 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif', + sans: 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif', + serif: 'Georgia, "Times New Roman", "Songti SC", "SimSun", serif', + }; + + return { + "--app-font-family": fontMap[font], + }; +} + +function radiusVariables(radius: RadiusChoice): Record { + const radiusMap: Record = { + auto: ["7px", "8px", "12px"], + "0": ["0px", "0px", "0px"], + "0.3": ["5px", "7px", "10px"], + "0.5": ["8px", "11px", "14px"], + "0.75": ["12px", "16px", "20px"], + "1": ["18px", "24px", "28px"], + }; + const [sm, md, lg] = radiusMap[radius]; + return { + "--radius-sm": sm, + "--radius-md": md, + "--radius-lg": lg, + }; +} + +function densityVariables(density: DensityChoice): Record { + const densityMap: Record> = { + compact: { + "--topbar-height": "64px", + "--nav-height": "42px", + "--control-height": "40px", + "--base-font-size": "15px", + "--page-padding": "24px", + "--content-gap": "18px", + "--panel-padding": "20px", + "--table-cell-y": "12px", + "--table-cell-x": "18px", + }, + default: { + "--topbar-height": "72px", + "--nav-height": "48px", + "--control-height": "44px", + "--base-font-size": "16px", + "--page-padding": "32px", + "--content-gap": "24px", + "--panel-padding": "24px", + "--table-cell-y": "18px", + "--table-cell-x": "24px", + }, + comfortable: { + "--topbar-height": "78px", + "--nav-height": "54px", + "--control-height": "48px", + "--base-font-size": "16px", + "--page-padding": "36px", + "--content-gap": "28px", + "--panel-padding": "28px", + "--table-cell-y": "22px", + "--table-cell-x": "28px", + }, + large: { + "--topbar-height": "88px", + "--nav-height": "62px", + "--control-height": "56px", + "--base-font-size": "18px", + "--page-padding": "44px", + "--content-gap": "34px", + "--panel-padding": "34px", + "--table-cell-y": "28px", + "--table-cell-x": "32px", + }, + }; + return densityMap[density]; +} + +function layoutVariables( + layout: LayoutChoice, + sidebar: SidebarChoice, + contentWidth: ContentWidthChoice, +): Record { + const sidebarWidth = layout === "compact" ? "252px" : "280px"; + const sidebarOffset = layout === "fullscreen" ? "0px" : sidebar === "floating" ? `calc(${sidebarWidth} + 24px)` : sidebarWidth; + + return { + "--layout-sidebar-width": sidebarWidth, + "--layout-sidebar-offset": sidebarOffset, + "--content-max-width": contentWidth === "centered" ? "1180px" : "none", + }; +} + +function readThemeName(): ThemeName { + const stored = readRawSetting(THEME_KEY); + const migrated = stored ? legacyThemeMap[stored] || stored : DEFAULTS.theme; + return themeOptions.some((item) => item.name === migrated) ? (migrated as ThemeName) : DEFAULTS.theme; +} + +function readSetting(key: string, fallback: T[number], values: T): T[number] { + const stored = readRawSetting(key) as T[number] | null; + return values.includes(stored as T[number]) ? stored! : fallback; +} + +function readRawSetting(key: string): string | null { + try { + return typeof localStorage === "undefined" ? null : localStorage.getItem(key); + } catch { + return null; + } +} + +function persistSetting(key: string, value: string): void { + try { + if (typeof localStorage !== "undefined") { + localStorage.setItem(key, value); + } + } catch { + // Local storage can be disabled in embedded browser contexts. + } +} + +function resolveThemeMode(mode: ThemeMode): "light" | "dark" { + if (mode !== "system") { + return mode; + } + return typeof window !== "undefined" && window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light"; +} + +let systemThemeWatching = false; + +function watchSystemTheme(apply: () => void): void { + if (systemThemeWatching || typeof window === "undefined" || !window.matchMedia) { + return; + } + + const query = window.matchMedia("(prefers-color-scheme: dark)"); + query.addEventListener?.("change", apply); + systemThemeWatching = true; +} + +function mix(hex: string, targetHex: string, percent: number): string { + const source = hexToRgb(hex); + const target = hexToRgb(targetHex); + const ratio = percent / 100; + return rgbToHex({ + r: Math.round(source.r + (target.r - source.r) * ratio), + g: Math.round(source.g + (target.g - source.g) * ratio), + b: Math.round(source.b + (target.b - source.b) * ratio), + }); +} + +function hexToRgba(hex: string, alpha: number): string { + const { r, g, b } = hexToRgb(hex); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} + +function hexToRgbCsv(hex: string): string { + const { r, g, b } = hexToRgb(hex); + return `${r}, ${g}, ${b}`; +} + +function hexToRgb(hex: string): { r: number; g: number; b: number } { + const normalized = hex.replace("#", ""); + return { + r: parseInt(normalized.slice(0, 2), 16), + g: parseInt(normalized.slice(2, 4), 16), + b: parseInt(normalized.slice(4, 6), 16), + }; +} + +function rgbToHex({ r, g, b }: { r: number; g: number; b: number }): string { + return `#${[r, g, b] + .map((value) => value.toString(16).padStart(2, "0")) + .join("")}`; +} diff --git a/frontend/src/views/CommissionsView.vue b/frontend/src/views/CommissionsView.vue new file mode 100644 index 0000000..165c0d6 --- /dev/null +++ b/frontend/src/views/CommissionsView.vue @@ -0,0 +1,360 @@ + + +