From faa09101a18f1b2727aceed4653bee34fc8274e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=84=A6=E9=BE=99=E8=A8=80?= Date: Mon, 22 Jun 2026 13:23:04 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E9=83=A8=E5=88=86=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E5=AE=9E=E7=8E=B0=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 39 +- .../2026-06-18-monthly-payroll-center.md | 1412 +++++++++++++++++ financial_system/api/app.py | 17 +- financial_system/api/dependencies.py | 31 + .../api/routers/monthly_payroll.py | 285 ++++ .../api/routers/realtime_attendance.py | 95 ++ financial_system/api/schemas/__init__.py | 20 + .../api/schemas/monthly_payroll.py | 61 + .../api/schemas/realtime_attendance.py | 55 + financial_system/domain/payroll_exceptions.py | 77 + financial_system/repositories/__init__.py | 4 + .../monthly_payroll_repository.py | 122 ++ .../realtime_attendance_repository.py | 147 ++ financial_system/services/__init__.py | 5 + .../services/monthly_payroll_service.py | 187 +++ financial_system/services/payroll_service.py | 8 + .../services/realtime_attendance_service.py | 395 +++++ frontend/README.md | 12 +- frontend/src/api/monthlyPayroll.ts | 68 + frontend/src/api/realtimeAttendance.ts | 16 + frontend/src/api/types.ts | 77 + frontend/src/assets/styles.css | 581 +++++-- frontend/src/components/AppearancePanel.vue | 51 +- frontend/src/stores/theme.ts | 145 +- frontend/src/views/MonthlyPayrollView.vue | 261 ++- frontend/src/views/OrganizationView.vue | 623 ++++++-- frontend/src/views/RealtimeAttendanceView.vue | 188 ++- frontend/tests/appearanceTheme.test.ts | 36 + frontend/tests/organizationTreeLayout.test.ts | 41 + frontend/tests/payrollCenterFlow.test.ts | 56 + frontend/tests/visualPolishCss.test.ts | 51 + 31 files changed, 4859 insertions(+), 307 deletions(-) create mode 100644 docs/plans/2026-06-18-monthly-payroll-center.md create mode 100644 financial_system/api/routers/monthly_payroll.py create mode 100644 financial_system/api/routers/realtime_attendance.py create mode 100644 financial_system/api/schemas/monthly_payroll.py create mode 100644 financial_system/api/schemas/realtime_attendance.py create mode 100644 financial_system/domain/payroll_exceptions.py create mode 100644 financial_system/repositories/monthly_payroll_repository.py create mode 100644 financial_system/repositories/realtime_attendance_repository.py create mode 100644 financial_system/services/monthly_payroll_service.py create mode 100644 financial_system/services/realtime_attendance_service.py create mode 100644 frontend/src/api/monthlyPayroll.ts create mode 100644 frontend/src/api/realtimeAttendance.ts create mode 100644 frontend/tests/appearanceTheme.test.ts create mode 100644 frontend/tests/organizationTreeLayout.test.ts create mode 100644 frontend/tests/payrollCenterFlow.test.ts create mode 100644 frontend/tests/visualPolishCss.test.ts diff --git a/README.md b/README.md index e312baa..61f6c44 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,8 @@ ## 当前能力 - 支持导入钉钉月度汇总 Excel 计算工资。 -- 支持预留钉钉实时打卡接口计算入口。 +- 支持实时考勤看板:按员工档案中的钉钉 userId 自动同步本月至今打卡,不需要人工输入 userId。 +- 支持月度核算中心:按月份导入 Excel 或同步钉钉计算,生成异常清单、核算批次、锁定状态和导出入口。 - 自动统计出勤天数、缺勤天数、请假工时、缺卡、迟到扣款。 - 按下班打卡时间自动计算加班:17:00 下班,18:00 计 1 小时,20:30 计 3 小时。 - 自动计算:`剩余加班工时 = 打卡推算加班工时 - 请假工时`。 @@ -28,6 +29,7 @@ - 加班费支持:工作日、周末、法定节假日独立单价。 - 提成支持手工维护和 Excel 导入,并按工资月份自动汇总进工资计算。 - 工资计算结果会落库为工资汇总、工资明细、考勤记录、请假记录和加班记录。 +- 实时薪资预估只用于过程查看,最终工资以月度核算锁定结果为准。 ## 项目结构 @@ -144,6 +146,7 @@ mysql -h localhost -P 3306 -u root -p12345678 < financial_system/database/sql/in ```bash mysql -h localhost -P 3306 -u root -p12345678 financial_system < financial_system/database/sql/upgrade_20260617_payroll_modules.sql +mysql -h localhost -P 3306 -u root -p12345678 financial_system < financial_system/database/sql/upgrade_20260618_monthly_payroll_center.sql ``` SQL 文件说明: @@ -154,6 +157,7 @@ SQL 文件说明: | `seed.sql` | 初始化默认超级管理员 | | `init_mysql.sql` | 一键初始化脚本,包含建库、建表、默认配置、默认管理员 | | `upgrade_20260617_payroll_modules.sql` | 当前薪酬考勤完整模块升级脚本 | +| `upgrade_20260618_monthly_payroll_center.sql` | 实时考勤同步、薪资预估、月度核算批次和异常清单升级脚本 | 主要业务表: @@ -170,6 +174,10 @@ SQL 文件说明: | `commission_record` | 提成/奖金记录 | | `payroll_jobs` | 工资计算任务 | | `payroll_results` | 员工工资计算结果 | +| `attendance_sync_jobs` | 钉钉/Excel 考勤同步批次 | +| `salary_preview` | 本月进行中的薪资预估 | +| `monthly_payroll_runs` | 月度正式核算批次 | +| `payroll_exceptions` | 月度核算异常清单 | | `salary_record` | 月度工资汇总 | | `salary_detail` | 工资明细项 | | `salary_config` | 规则配置中心 | @@ -227,6 +235,8 @@ config/salary_rules.example.json | --- | --- | --- | | 登录 | `/login` | 用户登录 | | 工作台 | `/dashboard` | 系统概览 | +| 实时考勤 | `/attendance/realtime` | 查看今日打卡、迟到、缺卡、请假、加班和本月薪资预估 | +| 月度核算 | `/payroll/monthly` | 按月导入/同步、计算、异常处理、锁定和导出 | | 工资计算 | `/payroll` | Excel 导入和钉钉实时计算 | | 计算记录 | `/payroll/jobs` | 查看历史计算任务 | | 提成管理 | `/payroll/commissions` | 手工维护和 Excel 导入提成 | @@ -277,7 +287,32 @@ curl -X POST "http://127.0.0.1:8000/api/payroll/excel" \ -F "export_excel=true" ``` -钉钉实时计算: +实时考勤看板同步钉钉: + +```bash +curl -X POST "http://127.0.0.1:8000/api/attendance/realtime/sync" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"attendance_date":"2026-06-22","include_salary_preview":true}' +``` + +查看实时考勤看板: + +```bash +curl "http://127.0.0.1:8000/api/attendance/realtime/today?date=2026-06-22" \ + -H "Authorization: Bearer " +``` + +月度核算同步钉钉并计算: + +```bash +curl -X POST "http://127.0.0.1:8000/api/payroll/monthly/dingtalk" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"salary_month":"2026-06","source_type":"dingtalk","export_excel":true}' +``` + +旧版钉钉实时计算接口仍可使用,但需要手动传 user_ids: ```bash curl -X POST "http://127.0.0.1:8000/api/payroll/dingtalk/realtime" \ diff --git a/docs/plans/2026-06-18-monthly-payroll-center.md b/docs/plans/2026-06-18-monthly-payroll-center.md new file mode 100644 index 0000000..39d1672 --- /dev/null +++ b/docs/plans/2026-06-18-monthly-payroll-center.md @@ -0,0 +1,1412 @@ +# 月度核算中心与实时考勤看板 Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 将当前薪酬考勤系统从“多个功能页分散操作”升级为“实时考勤看板 + 月度核算中心”的主流程系统,支持钉钉实时同步、Excel 导入、考勤标准化、薪资预估、月度工资复核、锁定和导出。 + +**Architecture:** 后端继续沿用 FastAPI 分层架构:router 只处理 HTTP 和权限,schema 只定义请求响应,service 负责编排,repository 负责 ORM 读写,domain 负责纯计算。新增实时考勤和月度核算模块时,复用现有 `PayrollCalculator`、`MonthlySummaryParser`、`DingTalkAttendanceAdapter`、`attendance_record`、`payroll_results` 等能力,只补齐同步批次、实时预估、月度核算状态和异常记录。 + +**Tech Stack:** Python 3 / FastAPI / SQLAlchemy / MySQL / openpyxl / Vue 3 / Pinia / Vue Router / TypeScript / Vite / lucide-vue。 + +--- + +## 目标业务流 + +```text +实时考勤看板 + -> 读取在职员工和钉钉 userId + -> 同步今天或本月至今的钉钉打卡 + -> 标准化为每日考勤 + -> 展示今日打卡、迟到、缺卡、请假、加班和本月薪资预估 + +月度核算中心 + -> 选择工资月份 + -> 同步钉钉或导入 Excel + -> 标准化考勤 + -> 生成异常清单 + -> 计算工资 + -> 复核员工明细 + -> 锁定本月工资 + -> 导出工资表和报表 +``` + +## 阶段 0:开发前保护 + +### Task 0.1: 建立当前基线 + +**Files:** +- Read: `README.md` +- Read: `financial_system/database/orm.py` +- Read: `financial_system/services/payroll_service.py` +- Read: `financial_system/api/routers/payroll.py` +- Read: `frontend/src/router/index.ts` +- Read: `frontend/src/components/SidebarNav.vue` +- Read: `frontend/src/views/PayrollView.vue` + +**Step 1: 查看工作区状态** + +Run: + +```bash +git status --short +``` + +Expected: 确认哪些文件已有用户改动。不要回滚任何非本任务改动。 + +**Step 2: 编译当前后端** + +Run: + +```bash +/Users/jiaolongyan/miniconda3/envs/Financial_System/bin/python -m compileall financial_system +``` + +Expected: `financial_system` 编译成功。 + +**Step 3: 构建当前前端** + +Run: + +```bash +cd frontend +npm run build +``` + +Expected: Vite build 通过。 + +**Step 4: Commit** + +本任务只做基线验证,不需要 commit。 + +--- + +## 阶段 1:数据库模型与 SQL + +### Task 1.1: 增加实时同步和月度核算 ORM + +**Files:** +- Modify: `financial_system/database/orm.py` +- Modify: `financial_system/database/sql/schema.sql` +- Modify: `financial_system/database/sql/init_mysql.sql` +- Create: `financial_system/database/sql/upgrade_20260618_monthly_payroll_center.sql` + +**Step 1: 设计新增表** + +新增 4 张表,所有表和字段都必须有 MySQL 注释: + +```text +attendance_sync_jobs + id + source_type + sync_mode + attendance_date + salary_month + start_date + end_date + status + employee_count + record_count + error_message + created_by + created_at + updated_at + +salary_preview + id + sync_job_id + employee_id + employee_no + employee_name + department + position + salary_month + attendance_days + actual_work_hours + punch_overtime_hours + leave_hours + remaining_overtime_hours + late_deduction + missing_card_deduction + commission_amount + overtime_pay + estimated_gross_salary + estimated_net_salary + status + note + created_at + updated_at + +monthly_payroll_runs + id + salary_month + source_type + source_job_id + status + employee_count + gross_total + net_total + deduction_total + overtime_total + locked_by + locked_at + created_by + created_at + updated_at + +payroll_exceptions + id + run_id + sync_job_id + employee_id + employee_no + employee_name + salary_month + exception_type + severity + message + status + created_at + resolved_at +``` + +**Step 2: 修改 ORM** + +在 `financial_system/database/orm.py` 中新增: + +```python +class AttendanceSyncJobORM(Base): + """钉钉或 Excel 考勤同步批次,用于实时看板和月度核算追踪。""" + __tablename__ = "attendance_sync_jobs" + ... + +class SalaryPreviewORM(Base): + """本月进行中的薪资预估结果,不作为最终工资发放依据。""" + __tablename__ = "salary_preview" + ... + +class MonthlyPayrollRunORM(Base): + """月度正式核算批次,支持复核、锁定和导出。""" + __tablename__ = "monthly_payroll_runs" + ... + +class PayrollExceptionORM(Base): + """核算异常记录,例如缺员工、缺薪资档案、缺卡、请假无法识别。""" + __tablename__ = "payroll_exceptions" + ... +``` + +字段类型沿用现有风格:金额用 `Float`,月份用 `String(7)`,状态用 `String(32)`,时间用 `DateTime`。 + +**Step 3: 更新 SQL** + +在 `schema.sql` 和 `init_mysql.sql` 中增加建表语句。新增 `upgrade_20260618_monthly_payroll_center.sql`,包含 `CREATE TABLE IF NOT EXISTS`,可对已有库安全执行。 + +**Step 4: 编译验证** + +Run: + +```bash +/Users/jiaolongyan/miniconda3/envs/Financial_System/bin/python -m compileall financial_system +``` + +Expected: PASS。 + +**Step 5: Commit** + +```bash +git add financial_system/database/orm.py financial_system/database/sql/schema.sql financial_system/database/sql/init_mysql.sql financial_system/database/sql/upgrade_20260618_monthly_payroll_center.sql +git commit -m "feat: add monthly payroll center tables" +``` + +--- + +### Task 1.2: 增加权限和菜单 + +**Files:** +- Modify: `financial_system/core/permissions.py` +- Modify: `frontend/src/router/index.ts` +- Modify: `frontend/src/components/SidebarNav.vue` + +**Step 1: 增加权限码** + +在 `financial_system/core/permissions.py` 增加: + +```python +PERMISSION_ATTENDANCE_REALTIME_VIEW = "attendance:realtime:view" +PERMISSION_ATTENDANCE_REALTIME_SYNC = "attendance:realtime:sync" +PERMISSION_MONTHLY_PAYROLL_VIEW = "monthly_payroll:view" +PERMISSION_MONTHLY_PAYROLL_CALCULATE = "monthly_payroll:calculate" +PERMISSION_MONTHLY_PAYROLL_LOCK = "monthly_payroll:lock" +PERMISSION_MONTHLY_PAYROLL_EXPORT = "monthly_payroll:export" +``` + +**Step 2: 增加菜单** + +新增菜单: + +```python +"attendance_realtime": MenuPermission( + code="attendance_realtime", + name="实时考勤", + path="/attendance/realtime", +), +"monthly_payroll": MenuPermission( + code="monthly_payroll", + name="月度核算", + path="/payroll/monthly", +), +``` + +超级用户和管理者可见两个菜单;查看权限至少可见 `实时考勤` 和报表,是否能看月度工资按业务需要决定,默认仅查看 `实时考勤`。 + +**Step 3: 前端路由占位** + +在 `frontend/src/router/index.ts` 先注册页面: + +```typescript +{ + path: "attendance/realtime", + name: "attendance-realtime", + component: RealtimeAttendanceView, + meta: { permission: "attendance:realtime:view" }, +}, +{ + path: "payroll/monthly", + name: "monthly-payroll", + component: MonthlyPayrollView, + meta: { permission: "monthly_payroll:view" }, +}, +``` + +**Step 4: 侧边栏图标** + +在 `frontend/src/components/SidebarNav.vue` 中增加图标映射: + +```typescript +attendance_realtime: CalendarClock, +monthly_payroll: Calculator, +``` + +**Step 5: 验证** + +Run: + +```bash +cd frontend +npm run build +``` + +Expected: PASS。 + +**Step 6: Commit** + +```bash +git add financial_system/core/permissions.py frontend/src/router/index.ts frontend/src/components/SidebarNav.vue +git commit -m "feat: add realtime attendance and monthly payroll menus" +``` + +--- + +## 阶段 2:后端实时考勤看板 + +### Task 2.1: 增加实时考勤 schema + +**Files:** +- Create: `financial_system/api/schemas/realtime_attendance.py` +- Modify: `financial_system/api/schemas/__init__.py` + +**Step 1: 编写 schema** + +Create `financial_system/api/schemas/realtime_attendance.py`: + +```python +from __future__ import annotations + +from datetime import date, datetime +from pydantic import BaseModel + + +class RealtimeAttendanceSyncRequest(BaseModel): + attendance_date: date + include_salary_preview: bool = True + + +class TodayAttendanceItem(BaseModel): + employee_id: int | None + employee_no: str + employee_name: str + department: str + position: str + first_punch: str + last_punch: str + attendance_status: str + late_minutes: int + missing_card_count: int + leave_hours: float + today_overtime_hours: float + month_remaining_overtime_hours: float + estimated_net_salary: float | None + note: str + + +class RealtimeAttendanceSummary(BaseModel): + attendance_date: date + employee_total: int + checked_in_count: int + unchecked_count: int + late_count: int + leave_count: int + missing_card_count: int + estimated_overtime_count: int + estimated_net_total: float + + +class RealtimeAttendanceResponse(BaseModel): + sync_job_id: str | None + synced_at: datetime | None + summary: RealtimeAttendanceSummary + items: list[TodayAttendanceItem] +``` + +**Step 2: 编译验证** + +Run: + +```bash +/Users/jiaolongyan/miniconda3/envs/Financial_System/bin/python -m compileall financial_system +``` + +Expected: PASS。 + +**Step 3: Commit** + +```bash +git add financial_system/api/schemas/realtime_attendance.py financial_system/api/schemas/__init__.py +git commit -m "feat: add realtime attendance schemas" +``` + +--- + +### Task 2.2: 增加实时考勤 Repository + +**Files:** +- Create: `financial_system/repositories/realtime_attendance_repository.py` +- Modify: `financial_system/repositories/__init__.py` + +**Step 1: 实现 repository** + +Repository 负责: + +- 查询在职员工和钉钉 userId。 +- 创建 `attendance_sync_jobs`。 +- 保存 `attendance_record`。 +- 保存 `salary_preview`。 +- 查询某日考勤记录。 +- 查询某月薪资预估。 + +核心方法: + +```python +class RealtimeAttendanceRepository: + def list_active_employees_with_dingtalk(self) -> list[EmployeeORM]: ... + def create_sync_job(...) -> AttendanceSyncJobORM: ... + def mark_sync_completed(...) -> None: ... + def mark_sync_failed(...) -> None: ... + def replace_attendance_records(...) -> None: ... + def replace_salary_previews(...) -> None: ... + def list_today_records(...) -> list[AttendanceRecordORM]: ... + def list_salary_previews(...) -> list[SalaryPreviewORM]: ... +``` + +**Step 2: 注意幂等** + +同一天多次同步时,应按 `source_type=dingtalk + work_date + employee_id` 替换当天记录,避免列表越来越多。 + +**Step 3: 编译验证** + +Run: + +```bash +/Users/jiaolongyan/miniconda3/envs/Financial_System/bin/python -m compileall financial_system +``` + +Expected: PASS。 + +**Step 4: Commit** + +```bash +git add financial_system/repositories/realtime_attendance_repository.py financial_system/repositories/__init__.py +git commit -m "feat: add realtime attendance repository" +``` + +--- + +### Task 2.3: 增加实时薪资预估服务 + +**Files:** +- Create: `financial_system/services/realtime_attendance_service.py` +- Modify: `financial_system/services/__init__.py` +- Modify: `financial_system/api/dependencies.py` + +**Step 1: 实现服务职责** + +`RealtimeAttendanceService` 负责: + +- 从数据库拿在职员工。 +- 自动收集员工 `dingtalk_user_id`,不再让用户手填。 +- 调用 `DingTalkClient.list_attendance_records`。 +- 使用 `DingTalkAttendanceAdapter` 转成 `EmployeeAttendance`。 +- 使用 `PayrollCalculator` 计算本月薪资预估。 +- 写入 `attendance_record` 和 `salary_preview`。 +- 输出今日考勤列表和汇总。 + +**Step 2: 保持预估语义** + +返回字段命名使用 `estimated_*`,页面文案必须展示“预估金额,最终以月度核算锁定结果为准”。 + +**Step 3: 缺少钉钉配置** + +复用 `PayrollApplicationService._dingtalk_settings()` 的逻辑,或者抽出公共 helper。缺少 `app_key/app_secret` 时返回 400,提示用户修改 `config/app_settings.json`。 + +**Step 4: 编译验证** + +Run: + +```bash +/Users/jiaolongyan/miniconda3/envs/Financial_System/bin/python -m compileall financial_system +``` + +Expected: PASS。 + +**Step 5: Commit** + +```bash +git add financial_system/services/realtime_attendance_service.py financial_system/services/__init__.py financial_system/api/dependencies.py +git commit -m "feat: add realtime attendance service" +``` + +--- + +### Task 2.4: 增加实时考勤 API + +**Files:** +- Create: `financial_system/api/routers/realtime_attendance.py` +- Modify: `financial_system/api/app.py` + +**Step 1: 新增路由** + +Create router prefix: + +```python +router = APIRouter(prefix="/api/attendance/realtime", tags=["realtime-attendance"]) +``` + +Endpoints: + +```text +GET /api/attendance/realtime/today?date=YYYY-MM-DD +POST /api/attendance/realtime/sync +``` + +**Step 2: 权限** + +- `GET today` 使用 `PERMISSION_ATTENDANCE_REALTIME_VIEW` +- `POST sync` 使用 `PERMISSION_ATTENDANCE_REALTIME_SYNC` + +**Step 3: 操作日志** + +同步成功记录: + +```text +module=attendance +action=attendance.realtime.sync +target_type=attendance_sync_job +``` + +查看成功记录: + +```text +module=attendance +action=attendance.realtime.view +``` + +**Step 4: 注册路由** + +在 `financial_system/api/app.py` 导入并 `include_router(realtime_attendance.router)`。 + +**Step 5: 编译验证** + +Run: + +```bash +/Users/jiaolongyan/miniconda3/envs/Financial_System/bin/python -m compileall financial_system +``` + +Expected: PASS。 + +**Step 6: Commit** + +```bash +git add financial_system/api/routers/realtime_attendance.py financial_system/api/app.py +git commit -m "feat: add realtime attendance api" +``` + +--- + +## 阶段 3:后端月度核算中心 + +### Task 3.1: 增加月度核算 schema + +**Files:** +- Create: `financial_system/api/schemas/monthly_payroll.py` +- Modify: `financial_system/api/schemas/__init__.py` + +**Step 1: 定义请求响应** + +Create: + +```python +class MonthlyPayrollCreateRequest(BaseModel): + salary_month: str + source_type: str + export_excel: bool = True + + +class MonthlyPayrollImportExcelResponse(BaseModel): + run_id: int + status: str + employee_count: int + exception_count: int + + +class MonthlyPayrollRunResponse(BaseModel): + id: int + salary_month: str + source_type: str + status: str + employee_count: int + gross_total: float + net_total: float + deduction_total: float + overtime_total: float + locked_by: str + locked_at: datetime | None + created_at: datetime + updated_at: datetime + + +class MonthlyPayrollDetailResponse(BaseModel): + run: MonthlyPayrollRunResponse + results: list[PayrollResultItem] + exceptions: list[PayrollExceptionItem] +``` + +`PayrollResultItem` 可复用现有 `financial_system/api/schemas/payroll.py` 中的结果 schema,避免重复字段。 + +**Step 2: 编译验证** + +Run: + +```bash +/Users/jiaolongyan/miniconda3/envs/Financial_System/bin/python -m compileall financial_system +``` + +Expected: PASS。 + +**Step 3: Commit** + +```bash +git add financial_system/api/schemas/monthly_payroll.py financial_system/api/schemas/__init__.py +git commit -m "feat: add monthly payroll schemas" +``` + +--- + +### Task 3.2: 增加月度核算 Repository + +**Files:** +- Create: `financial_system/repositories/monthly_payroll_repository.py` +- Modify: `financial_system/repositories/__init__.py` + +**Step 1: 实现方法** + +Repository 负责: + +```python +class MonthlyPayrollRepository: + def create_run(...) -> MonthlyPayrollRunORM: ... + def get_run(run_id: int) -> MonthlyPayrollRunORM | None: ... + def get_run_by_month(salary_month: str) -> MonthlyPayrollRunORM | None: ... + def update_run_totals(...) -> None: ... + def lock_run(...) -> None: ... + def replace_exceptions(...) -> None: ... + def list_exceptions(...) -> list[PayrollExceptionORM]: ... +``` + +**Step 2: 锁定保护** + +如果 `monthly_payroll_runs.status == "locked"`,任何重新计算或覆盖结果都必须拒绝。 + +**Step 3: 编译验证** + +Run: + +```bash +/Users/jiaolongyan/miniconda3/envs/Financial_System/bin/python -m compileall financial_system +``` + +Expected: PASS。 + +**Step 4: Commit** + +```bash +git add financial_system/repositories/monthly_payroll_repository.py financial_system/repositories/__init__.py +git commit -m "feat: add monthly payroll repository" +``` + +--- + +### Task 3.3: 增加异常识别规则 + +**Files:** +- Create: `financial_system/domain/payroll_exceptions.py` + +**Step 1: 实现纯函数** + +Create: + +```python +def detect_payroll_exceptions(results: list[PayrollResult]) -> list[PayrollException]: + ... +``` + +异常类型: + +```text +missing_salary_profile +missing_employee_match +missing_dingtalk_user_id +missing_card +late_penalty +negative_remaining_overtime +empty_attendance +``` + +**Step 2: 规则建议** + +- `base_pay is None` -> `missing_salary_profile` +- `missing_card_count > 0` -> `missing_card` +- `penalized_late_count > 0` -> `late_penalty` +- `remaining_overtime_hours < 0` -> `negative_remaining_overtime` +- `attendance_days == 0` -> `empty_attendance` + +**Step 3: 编译验证** + +Run: + +```bash +/Users/jiaolongyan/miniconda3/envs/Financial_System/bin/python -m compileall financial_system +``` + +Expected: PASS。 + +**Step 4: Commit** + +```bash +git add financial_system/domain/payroll_exceptions.py +git commit -m "feat: add payroll exception detection" +``` + +--- + +### Task 3.4: 增加月度核算服务 + +**Files:** +- Create: `financial_system/services/monthly_payroll_service.py` +- Modify: `financial_system/services/__init__.py` +- Modify: `financial_system/api/dependencies.py` + +**Step 1: 服务职责** + +`MonthlyPayrollService` 负责: + +- Excel 导入并创建月度 run。 +- 钉钉全量同步并创建月度 run。 +- 复用 `PayrollApplicationService` 或直接复用 parser/calculator。 +- 生成工资结果。 +- 生成异常记录。 +- 更新 run 汇总金额。 +- 锁定工资。 +- 导出工资文件。 + +**Step 2: 状态机** + +状态只保留必要值: + +```text +draft 草稿/已导入 +calculated 已计算 +reviewed 已复核 +locked 已锁定 +failed 失败 +``` + +**Step 3: 锁定规则** + +锁定后: + +- 不允许重新计算。 +- 不允许覆盖结果。 +- 允许导出。 +- 操作日志必须记录锁定人。 + +**Step 4: 编译验证** + +Run: + +```bash +/Users/jiaolongyan/miniconda3/envs/Financial_System/bin/python -m compileall financial_system +``` + +Expected: PASS。 + +**Step 5: Commit** + +```bash +git add financial_system/services/monthly_payroll_service.py financial_system/services/__init__.py financial_system/api/dependencies.py +git commit -m "feat: add monthly payroll service" +``` + +--- + +### Task 3.5: 增加月度核算 API + +**Files:** +- Create: `financial_system/api/routers/monthly_payroll.py` +- Modify: `financial_system/api/app.py` + +**Step 1: 新增路由** + +Create router prefix: + +```python +router = APIRouter(prefix="/api/payroll/monthly", tags=["monthly-payroll"]) +``` + +Endpoints: + +```text +GET /api/payroll/monthly/runs?month=YYYY-MM +GET /api/payroll/monthly/runs/{run_id} +POST /api/payroll/monthly/excel +POST /api/payroll/monthly/dingtalk +POST /api/payroll/monthly/runs/{run_id}/recalculate +POST /api/payroll/monthly/runs/{run_id}/lock +GET /api/payroll/monthly/runs/{run_id}/export +``` + +**Step 2: 权限** + +- 查看:`PERMISSION_MONTHLY_PAYROLL_VIEW` +- 计算:`PERMISSION_MONTHLY_PAYROLL_CALCULATE` +- 锁定:`PERMISSION_MONTHLY_PAYROLL_LOCK` +- 导出:`PERMISSION_MONTHLY_PAYROLL_EXPORT` + +**Step 3: 操作日志** + +记录以下动作: + +```text +monthly_payroll.view +monthly_payroll.excel.import +monthly_payroll.dingtalk.calculate +monthly_payroll.recalculate +monthly_payroll.lock +monthly_payroll.export +``` + +**Step 4: 注册路由** + +在 `financial_system/api/app.py` 导入并 include。 + +**Step 5: 编译验证** + +Run: + +```bash +/Users/jiaolongyan/miniconda3/envs/Financial_System/bin/python -m compileall financial_system +``` + +Expected: PASS。 + +**Step 6: Commit** + +```bash +git add financial_system/api/routers/monthly_payroll.py financial_system/api/app.py +git commit -m "feat: add monthly payroll api" +``` + +--- + +## 阶段 4:前端信息架构和 API + +### Task 4.1: 重组菜单展示 + +**Files:** +- Modify: `frontend/src/components/SidebarNav.vue` +- Modify: `frontend/src/assets/styles.css` + +**Step 1: 分组菜单** + +将菜单视觉分组为: + +```text +工作台 +实时考勤 +月度核算 + 工资核算 + 计算记录 + 提成录入 +员工薪资 + 员工维护 + 薪资档案 + 组织架构 +报表分析 + 工资报表 +系统管理 + 规则配置 + 用户管理 + 操作日志 +``` + +**Step 2: 收起侧栏体验** + +收起时隐藏分组文字,只保留图标;悬停显示 `title`。保持现在的平滑过渡变量: + +```css +--motion-duration: 280ms; +--motion-easing: cubic-bezier(0.22, 1, 0.36, 1); +``` + +**Step 3: 构建验证** + +Run: + +```bash +cd frontend +npm run build +``` + +Expected: PASS。 + +**Step 4: Commit** + +```bash +git add frontend/src/components/SidebarNav.vue frontend/src/assets/styles.css +git commit -m "feat: group sidebar navigation" +``` + +--- + +### Task 4.2: 增加前端 API 类型 + +**Files:** +- Modify: `frontend/src/api/types.ts` +- Create: `frontend/src/api/realtimeAttendance.ts` +- Create: `frontend/src/api/monthlyPayroll.ts` + +**Step 1: 增加类型** + +在 `types.ts` 增加: + +```typescript +export interface RealtimeAttendanceSummary { ... } +export interface TodayAttendanceItem { ... } +export interface RealtimeAttendanceResponse { ... } +export interface MonthlyPayrollRun { ... } +export interface PayrollException { ... } +export interface MonthlyPayrollDetailResponse { ... } +``` + +**Step 2: 增加 API 方法** + +Create `realtimeAttendance.ts`: + +```typescript +export function getTodayAttendance(date: string): Promise { ... } +export function syncRealtimeAttendance(date: string): Promise { ... } +``` + +Create `monthlyPayroll.ts`: + +```typescript +export function listMonthlyRuns(month?: string): Promise { ... } +export function getMonthlyRun(runId: number): Promise { ... } +export function calculateMonthlyFromExcel(...): Promise { ... } +export function calculateMonthlyFromDingTalk(month: string): Promise { ... } +export function lockMonthlyRun(runId: number): Promise { ... } +export function exportMonthlyRun(runId: number): Promise { ... } +``` + +**Step 3: 构建验证** + +Run: + +```bash +cd frontend +npm run build +``` + +Expected: PASS。 + +**Step 4: Commit** + +```bash +git add frontend/src/api/types.ts frontend/src/api/realtimeAttendance.ts frontend/src/api/monthlyPayroll.ts +git commit -m "feat: add realtime and monthly payroll api clients" +``` + +--- + +## 阶段 5:实时考勤页面 + +### Task 5.1: 创建实时考勤页面 + +**Files:** +- Create: `frontend/src/views/RealtimeAttendanceView.vue` +- Modify: `frontend/src/router/index.ts` + +**Step 1: 页面布局** + +页面结构: + +```text +顶部:今日考勤与薪资预估 / 日期选择 / 同步钉钉 / 自动刷新开关 +摘要:应到人数、已打卡、未打卡、迟到、请假、预估加班 +主表:员工今日考勤 +右侧抽屉:员工本月明细 +``` + +**Step 2: 主表字段** + +```text +员工 +部门/岗位 +上班打卡 +下班打卡 +今日状态 +迟到分钟 +缺卡 +请假工时 +今日加班 +本月剩余加班 +本月预估实发 +``` + +**Step 3: 文案** + +页面必须展示: + +```text +预估金额,最终以月度核算锁定结果为准 +``` + +**Step 4: 视觉要求** + +- 不使用大面积营销式卡片。 +- 摘要卡片压扁,适合后台扫描。 +- 表格上方保留紧凑筛选栏。 +- 员工明细使用右侧抽屉,不跳页面。 + +**Step 5: 构建验证** + +Run: + +```bash +cd frontend +npm run build +``` + +Expected: PASS。 + +**Step 6: Commit** + +```bash +git add frontend/src/views/RealtimeAttendanceView.vue frontend/src/router/index.ts +git commit -m "feat: add realtime attendance view" +``` + +--- + +### Task 5.2: 接入实时考勤页面数据 + +**Files:** +- Modify: `frontend/src/views/RealtimeAttendanceView.vue` + +**Step 1: 加载数据** + +页面 mounted 后默认请求今天: + +```typescript +const today = new Date().toISOString().slice(0, 10); +await getTodayAttendance(today); +``` + +**Step 2: 同步钉钉** + +点击 `同步钉钉` 调用: + +```typescript +await syncRealtimeAttendance(selectedDate.value); +``` + +同步时按钮显示 loading,成功后刷新摘要和表格。 + +**Step 3: 错误处理** + +钉钉未配置时显示: + +```text +钉钉配置缺失,请先在 config/app_settings.json 中配置 app_key 和 app_secret +``` + +**Step 4: 构建验证** + +Run: + +```bash +cd frontend +npm run build +``` + +Expected: PASS。 + +**Step 5: Commit** + +```bash +git add frontend/src/views/RealtimeAttendanceView.vue +git commit -m "feat: connect realtime attendance data" +``` + +--- + +## 阶段 6:月度核算中心页面 + +### Task 6.1: 创建月度核算中心页面 + +**Files:** +- Create: `frontend/src/views/MonthlyPayrollView.vue` +- Modify: `frontend/src/router/index.ts` + +**Step 1: 页面结构** + +```text +顶部:月份选择 / 当前状态 / 同步钉钉 / 导入 Excel / 导出 / 锁定 +步骤条:数据导入 -> 考勤转换 -> 异常处理 -> 工资计算 -> 复核锁定 +摘要条:员工数、应发、实发、扣款、剩余加班 +主表:员工工资核算结果 +右侧抽屉:员工每日考勤和工资拆分 +异常面板:缺薪资档案、缺卡、迟到扣款、未匹配员工 +``` + +**Step 2: 状态文案** + +```text +draft: 草稿 +calculated: 已计算 +reviewed: 已复核 +locked: 已锁定 +failed: 失败 +``` + +**Step 3: 锁定按钮** + +`locked` 状态下禁用重新计算和导入,只保留导出。 + +**Step 4: 构建验证** + +Run: + +```bash +cd frontend +npm run build +``` + +Expected: PASS。 + +**Step 5: Commit** + +```bash +git add frontend/src/views/MonthlyPayrollView.vue frontend/src/router/index.ts +git commit -m "feat: add monthly payroll center view" +``` + +--- + +### Task 6.2: 接入月度核算 API + +**Files:** +- Modify: `frontend/src/views/MonthlyPayrollView.vue` + +**Step 1: 默认月份** + +默认当前月份: + +```typescript +const selectedMonth = ref(new Date().toISOString().slice(0, 7)); +``` + +**Step 2: 加载 run** + +进入页面或月份变化时加载: + +```typescript +await listMonthlyRuns(selectedMonth.value); +``` + +如果已有 run,自动打开最新 run;否则显示空状态和操作按钮。 + +**Step 3: Excel 导入** + +导入按钮使用隐藏 file input,不让用户填配置路径。 + +**Step 4: 钉钉计算** + +点击 `同步钉钉并计算`,后端自动读取在职员工 `dingtalk_user_id`。 + +**Step 5: 锁定确认** + +锁定前弹窗确认: + +```text +锁定后本月工资不能重新计算,只能导出。确认锁定? +``` + +**Step 6: 构建验证** + +Run: + +```bash +cd frontend +npm run build +``` + +Expected: PASS。 + +**Step 7: Commit** + +```bash +git add frontend/src/views/MonthlyPayrollView.vue +git commit -m "feat: connect monthly payroll center api" +``` + +--- + +## 阶段 7:工作台改造 + +### Task 7.1: 工作台改为流程总览 + +**Files:** +- Modify: `frontend/src/views/DashboardView.vue` +- Modify: `frontend/src/assets/styles.css` + +**Step 1: 改成经营总览** + +工作台展示: + +```text +今日考勤状态 +本月工资预估 +本月核算状态 +待处理异常 +最近同步任务 +``` + +**Step 2: 快捷入口** + +保留按钮: + +```text +进入实时考勤 +进入月度核算 +提成录入 +员工维护 +``` + +**Step 3: 移除假图表** + +现在 `chartBars` 是静态假数据。要么接后端报表,要么先移除,避免误导。 + +**Step 4: 构建验证** + +Run: + +```bash +cd frontend +npm run build +``` + +Expected: PASS。 + +**Step 5: Commit** + +```bash +git add frontend/src/views/DashboardView.vue frontend/src/assets/styles.css +git commit -m "feat: redesign dashboard around payroll workflow" +``` + +--- + +## 阶段 8:文档与验证 + +### Task 8.1: 更新 README + +**Files:** +- Modify: `README.md` +- Modify: `frontend/README.md` + +**Step 1: README 增加模块说明** + +补充: + +```text +实时考勤看板 +月度核算中心 +薪资预估和正式工资的区别 +锁定规则 +新增 SQL 升级脚本 +``` + +**Step 2: 更新运行说明** + +说明后端仍然使用: + +```bash +/Users/jiaolongyan/miniconda3/envs/Financial_System/bin/python main.py +``` + +前端仍然使用: + +```bash +cd frontend +npm run dev +``` + +**Step 3: Commit** + +```bash +git add README.md frontend/README.md +git commit -m "docs: update payroll center documentation" +``` + +--- + +### Task 8.2: 全量验证 + +**Files:** +- No source edits unless verification fails. + +**Step 1: 后端编译** + +Run: + +```bash +/Users/jiaolongyan/miniconda3/envs/Financial_System/bin/python -m compileall financial_system +``` + +Expected: PASS。 + +**Step 2: 前端构建** + +Run: + +```bash +cd frontend +npm run build +``` + +Expected: PASS。 + +**Step 3: 启动后端** + +Run: + +```bash +/Users/jiaolongyan/miniconda3/envs/Financial_System/bin/python main.py +``` + +Expected: 后端启动成功,`/health` 返回正常。 + +**Step 4: 启动前端** + +Run: + +```bash +cd frontend +npm run dev +``` + +Expected: 前端启动成功。 + +**Step 5: 浏览器验收** + +检查: + +```text +/attendance/realtime +/payroll/monthly +/dashboard +/system/employees +/system/salary-profiles +/payroll/commissions +``` + +验收点: + +- 菜单显示分组合理。 +- 实时考勤能进入,未配置钉钉时提示明确。 +- 月度核算能进入,Excel 导入入口存在。 +- 锁定状态不会允许重新计算。 +- 侧栏收起/展开仍然平滑。 +- 控制台无 error/warn。 + +**Step 6: Commit** + +若修复了验证问题,按实际文件提交: + +```bash +git add +git commit -m "fix: stabilize payroll center flow" +``` + +--- + +## 实施顺序建议 + +优先级从高到低: + +1. 阶段 1:数据库和菜单权限。 +2. 阶段 2:实时考勤后端。 +3. 阶段 5:实时考勤前端。 +4. 阶段 3:月度核算后端。 +5. 阶段 6:月度核算前端。 +6. 阶段 7:工作台改造。 +7. 阶段 8:文档和全量验证。 + +这样可以先让你看到“今天打卡和实时薪资预估”的效果,再继续完善月底正式核算。 + +## 风险点 + +- 钉钉接口真实字段可能和当前 `DingTalkAttendanceAdapter` 的假设不完全一致,需要用真实返回样例校准。 +- 实时薪资只能作为预估,不能覆盖正式工资结果。 +- 包月员工的“本月预估工资”要确认是否按自然日、工作日进度、还是直接展示已产生工资。 +- 计件员工如果没有每日计件数据,只能显示已录入计件金额,不能凭考勤自动估算。 +- 锁定工资后是否允许管理员解锁,需要单独确认。当前计划默认不提供解锁。 + +## 后续可选增强 + +- 自动同步定时任务:每天 09:30、12:00、18:30 同步钉钉。 +- 异常提醒:缺卡、迟到、未绑定钉钉 userId 自动出现在工作台。 +- 工资条:锁定后生成员工工资条。 +- 审批流:人事核算、财务复核、老板确认。 +- 导入计件数据:为计件员工增加单独的产量录入或 Excel 导入。 diff --git a/financial_system/api/app.py b/financial_system/api/app.py index f551d75..3eacb02 100644 --- a/financial_system/api/app.py +++ b/financial_system/api/app.py @@ -13,7 +13,20 @@ 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 +from .routers import ( + auth, + commissions, + configs, + employees, + health, + monthly_payroll, + operation_logs, + organization, + payroll, + realtime_attendance, + reports, + salary_profiles, +) logger = AppLogger.get_logger(__name__) @@ -56,9 +69,11 @@ def create_app() -> FastAPI: app.include_router(configs.router) app.include_router(employees.router) app.include_router(health.router) + app.include_router(monthly_payroll.router) app.include_router(operation_logs.router) app.include_router(organization.router) app.include_router(payroll.router) + app.include_router(realtime_attendance.router) app.include_router(reports.router) app.include_router(salary_profiles.router) return app diff --git a/financial_system/api/dependencies.py b/financial_system/api/dependencies.py index fc86444..8c276b4 100644 --- a/financial_system/api/dependencies.py +++ b/financial_system/api/dependencies.py @@ -16,9 +16,11 @@ from ..database.orm import UserORM from ..repositories import ( CommissionRepository, EmployeeRepository, + MonthlyPayrollRepository, OperationLogRepository, OrganizationRepository, PayrollRepository, + RealtimeAttendanceRepository, ReportRepository, SalaryConfigRepository, SalaryProfileRepository, @@ -28,9 +30,11 @@ from ..services import ( AuthService, CommissionService, EmployeeService, + MonthlyPayrollService, OperationLogService, OrganizationService, PayrollApplicationService, + RealtimeAttendanceService, ReportService, SalaryConfigService, SalaryProfileService, @@ -58,6 +62,14 @@ def get_payroll_repository(session: Session = Depends(get_db_session)) -> Payrol return PayrollRepository(session) +def get_monthly_payroll_repository(session: Session = Depends(get_db_session)) -> MonthlyPayrollRepository: + return MonthlyPayrollRepository(session) + + +def get_realtime_attendance_repository(session: Session = Depends(get_db_session)) -> RealtimeAttendanceRepository: + return RealtimeAttendanceRepository(session) + + def get_commission_repository(session: Session = Depends(get_db_session)) -> CommissionRepository: return CommissionRepository(session) @@ -162,6 +174,25 @@ def get_payroll_service( ) +def get_realtime_attendance_service( + repository: RealtimeAttendanceRepository = Depends(get_realtime_attendance_repository), + payroll_service: PayrollApplicationService = Depends(get_payroll_service), +) -> RealtimeAttendanceService: + return RealtimeAttendanceService(repository=repository, payroll_service=payroll_service) + + +def get_monthly_payroll_service( + repository: MonthlyPayrollRepository = Depends(get_monthly_payroll_repository), + payroll_service: PayrollApplicationService = Depends(get_payroll_service), + employee_repository: EmployeeRepository = Depends(get_employee_repository), +) -> MonthlyPayrollService: + return MonthlyPayrollService( + repository=repository, + payroll_service=payroll_service, + employee_repository=employee_repository, + ) + + def get_current_user( credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), repository: UserRepository = Depends(get_user_repository), diff --git a/financial_system/api/routers/monthly_payroll.py b/financial_system/api/routers/monthly_payroll.py new file mode 100644 index 0000000..4e997b8 --- /dev/null +++ b/financial_system/api/routers/monthly_payroll.py @@ -0,0 +1,285 @@ +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_MONTHLY_PAYROLL_CALCULATE, + PERMISSION_MONTHLY_PAYROLL_EXPORT, + PERMISSION_MONTHLY_PAYROLL_LOCK, + PERMISSION_MONTHLY_PAYROLL_VIEW, +) +from ...database.orm import MonthlyPayrollRunORM, PayrollExceptionORM, UserORM +from ...integrations.dingtalk import DingTalkError +from ...services import MonthlyPayrollDetailData, MonthlyPayrollService, OperationLogService +from ..dependencies import get_monthly_payroll_service, get_operation_log_service, require_permission +from ..request_meta import operation_request_meta +from ..schemas.monthly_payroll import ( + MonthlyPayrollCreateRequest, + MonthlyPayrollDetailResponse, + MonthlyPayrollRunResponse, + PayrollExceptionItem, +) +from ..schemas.payroll import PayrollResultDTO + +router = APIRouter(prefix="/api/payroll/monthly", tags=["monthly-payroll"]) +logger = AppLogger.get_logger(__name__) + + +@router.get("/runs", response_model=list[MonthlyPayrollRunResponse]) +def list_monthly_runs( + month: str | None = None, + _: UserORM = Depends(require_permission(PERMISSION_MONTHLY_PAYROLL_VIEW)), + service: MonthlyPayrollService = Depends(get_monthly_payroll_service), +) -> list[MonthlyPayrollRunResponse]: + """按月份查看月度核算批次。""" + return [_run_response(run) for run in service.list_runs(month)] + + +@router.get("/runs/{run_id}", response_model=MonthlyPayrollDetailResponse) +def get_monthly_run( + http_request: Request, + run_id: int, + current_user: UserORM = Depends(require_permission(PERMISSION_MONTHLY_PAYROLL_VIEW)), + service: MonthlyPayrollService = Depends(get_monthly_payroll_service), + operation_logs: OperationLogService = Depends(get_operation_log_service), +) -> MonthlyPayrollDetailResponse: + """查看月度核算批次明细、工资结果和异常清单。""" + try: + detail = service.get_detail(run_id) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + operation_logs.record( + actor=current_user, + module="monthly_payroll", + action="monthly_payroll.view", + target_type="monthly_payroll_run", + target_id=str(run_id), + status="success", + detail=f"查看月度核算 run_id={run_id}", + **operation_request_meta(http_request), + ) + return _detail_response(detail) + + +@router.post("/excel", response_model=MonthlyPayrollDetailResponse) +def calculate_monthly_from_excel( + http_request: Request, + file: UploadFile = File(...), + salary_month: str = Form(...), + export_excel: bool = Form(default=True), + current_user: UserORM = Depends(require_permission(PERMISSION_MONTHLY_PAYROLL_CALCULATE)), + service: MonthlyPayrollService = Depends(get_monthly_payroll_service), + operation_logs: OperationLogService = Depends(get_operation_log_service), +) -> MonthlyPayrollDetailResponse: + """导入 Excel 并生成月度正式核算批次。""" + try: + detail = service.calculate_from_excel( + salary_month=salary_month, + filename=file.filename or "attendance.xlsx", + stream=file.file, + created_by=current_user.username, + export_excel=export_excel, + ) + except ValueError as exc: + _record_failed(operation_logs, current_user, http_request, "monthly_payroll.excel.import", str(exc)) + raise HTTPException(status_code=400, detail=str(exc)) from exc + operation_logs.record( + actor=current_user, + module="monthly_payroll", + action="monthly_payroll.excel.import", + target_type="monthly_payroll_run", + target_id=str(detail.run.id), + status="success", + detail=f"Excel月度核算成功 salary_month={salary_month} employee_count={detail.run.employee_count}", + **operation_request_meta(http_request), + ) + return _detail_response(detail) + + +@router.post("/dingtalk", response_model=MonthlyPayrollDetailResponse) +async def calculate_monthly_from_dingtalk( + http_request: Request, + request: MonthlyPayrollCreateRequest, + current_user: UserORM = Depends(require_permission(PERMISSION_MONTHLY_PAYROLL_CALCULATE)), + service: MonthlyPayrollService = Depends(get_monthly_payroll_service), + operation_logs: OperationLogService = Depends(get_operation_log_service), +) -> MonthlyPayrollDetailResponse: + """按员工档案中的钉钉 userId 自动同步整月考勤并生成核算批次。""" + try: + detail = await service.calculate_from_dingtalk( + salary_month=request.salary_month, + created_by=current_user.username, + export_excel=request.export_excel, + ) + except ValueError as exc: + _record_failed(operation_logs, current_user, http_request, "monthly_payroll.dingtalk.calculate", str(exc)) + raise HTTPException(status_code=400, detail=str(exc)) from exc + except DingTalkError as exc: + _record_failed(operation_logs, current_user, http_request, "monthly_payroll.dingtalk.calculate", str(exc)) + raise HTTPException(status_code=502, detail=str(exc)) from exc + operation_logs.record( + actor=current_user, + module="monthly_payroll", + action="monthly_payroll.dingtalk.calculate", + target_type="monthly_payroll_run", + target_id=str(detail.run.id), + status="success", + detail=f"钉钉月度核算成功 salary_month={request.salary_month} employee_count={detail.run.employee_count}", + **operation_request_meta(http_request), + ) + return _detail_response(detail) + + +@router.post("/runs/{run_id}/recalculate", response_model=MonthlyPayrollDetailResponse) +async def recalculate_monthly_run( + http_request: Request, + run_id: int, + current_user: UserORM = Depends(require_permission(PERMISSION_MONTHLY_PAYROLL_CALCULATE)), + service: MonthlyPayrollService = Depends(get_monthly_payroll_service), + operation_logs: OperationLogService = Depends(get_operation_log_service), +) -> MonthlyPayrollDetailResponse: + """重新计算钉钉来源的月度核算批次。""" + detail = service.get_detail(run_id) + if detail.run.status == "locked": + raise HTTPException(status_code=400, detail="本月工资已锁定,不能重新计算") + if detail.run.source_type != "dingtalk": + raise HTTPException(status_code=400, detail="Excel 来源请重新导入文件后计算") + detail = await service.calculate_from_dingtalk( + salary_month=detail.run.salary_month, + created_by=current_user.username, + export_excel=True, + ) + operation_logs.record( + actor=current_user, + module="monthly_payroll", + action="monthly_payroll.recalculate", + target_type="monthly_payroll_run", + target_id=str(run_id), + status="success", + detail=f"重新计算月度核算 run_id={run_id}", + **operation_request_meta(http_request), + ) + return _detail_response(detail) + + +@router.post("/runs/{run_id}/lock", response_model=MonthlyPayrollRunResponse) +def lock_monthly_run( + http_request: Request, + run_id: int, + current_user: UserORM = Depends(require_permission(PERMISSION_MONTHLY_PAYROLL_LOCK)), + service: MonthlyPayrollService = Depends(get_monthly_payroll_service), + operation_logs: OperationLogService = Depends(get_operation_log_service), +) -> MonthlyPayrollRunResponse: + """锁定月度工资,锁定后不允许重新计算或覆盖。""" + try: + run = service.lock_run(run_id, current_user.username) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + operation_logs.record( + actor=current_user, + module="monthly_payroll", + action="monthly_payroll.lock", + target_type="monthly_payroll_run", + target_id=str(run_id), + status="success", + detail=f"锁定月度核算 run_id={run_id}", + **operation_request_meta(http_request), + ) + return _run_response(run) + + +@router.get("/runs/{run_id}/export") +def export_monthly_run( + http_request: Request, + run_id: int, + current_user: UserORM = Depends(require_permission(PERMISSION_MONTHLY_PAYROLL_EXPORT)), + service: MonthlyPayrollService = Depends(get_monthly_payroll_service), + operation_logs: OperationLogService = Depends(get_operation_log_service), +) -> FileResponse: + """导出月度工资表。""" + try: + path = service.export_path(run_id) + except (ValueError, FileNotFoundError) as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + operation_logs.record( + actor=current_user, + module="monthly_payroll", + action="monthly_payroll.export", + target_type="monthly_payroll_run", + target_id=str(run_id), + status="success", + detail=f"导出月度核算 run_id={run_id} path={path}", + **operation_request_meta(http_request), + ) + return FileResponse( + path, + filename=path.name, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + + +def _detail_response(detail: MonthlyPayrollDetailData) -> MonthlyPayrollDetailResponse: + return MonthlyPayrollDetailResponse( + run=_run_response(detail.run), + results=[PayrollResultDTO.from_orm_model(result) for result in detail.job.results] if detail.job else [], + exceptions=[_exception_response(item) for item in detail.exceptions], + ) + + +def _run_response(run: MonthlyPayrollRunORM) -> MonthlyPayrollRunResponse: + return MonthlyPayrollRunResponse( + id=run.id, + salary_month=run.salary_month, + source_type=run.source_type, + source_job_id=run.source_job_id, + status=run.status, + employee_count=run.employee_count, + gross_total=run.gross_total, + net_total=run.net_total, + deduction_total=run.deduction_total, + overtime_total=run.overtime_total, + locked_by=run.locked_by, + locked_at=run.locked_at, + created_by=run.created_by, + created_at=run.created_at, + updated_at=run.updated_at, + ) + + +def _exception_response(item: PayrollExceptionORM) -> PayrollExceptionItem: + return PayrollExceptionItem( + id=item.id, + run_id=item.run_id, + sync_job_id=item.sync_job_id, + employee_id=item.employee_id, + employee_no=item.employee_no, + employee_name=item.employee_name, + salary_month=item.salary_month, + exception_type=item.exception_type, + severity=item.severity, + message=item.message, + status=item.status, + created_at=item.created_at, + resolved_at=item.resolved_at, + ) + + +def _record_failed( + operation_logs: OperationLogService, + current_user: UserORM, + http_request: Request, + action: str, + reason: str, +) -> None: + operation_logs.record( + actor=current_user, + module="monthly_payroll", + action=action, + target_type="monthly_payroll_run", + target_id="", + status="failed", + detail=reason, + **operation_request_meta(http_request), + ) diff --git a/financial_system/api/routers/realtime_attendance.py b/financial_system/api/routers/realtime_attendance.py new file mode 100644 index 0000000..5d51fbd --- /dev/null +++ b/financial_system/api/routers/realtime_attendance.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from datetime import date + +from fastapi import APIRouter, Depends, HTTPException, Query, Request + +from ...core.logger import AppLogger +from ...core.permissions import PERMISSION_ATTENDANCE_REALTIME_SYNC, PERMISSION_ATTENDANCE_REALTIME_VIEW +from ...database.orm import UserORM +from ...integrations.dingtalk import DingTalkError +from ...services import OperationLogService, RealtimeAttendanceService +from ..dependencies import get_operation_log_service, get_realtime_attendance_service, require_permission +from ..request_meta import operation_request_meta +from ..schemas.realtime_attendance import RealtimeAttendanceResponse, RealtimeAttendanceSyncRequest + +router = APIRouter(prefix="/api/attendance/realtime", tags=["realtime-attendance"]) +logger = AppLogger.get_logger(__name__) + + +@router.get("/today", response_model=RealtimeAttendanceResponse) +def get_today_attendance( + http_request: Request, + attendance_date: date = Query(alias="date"), + current_user: UserORM = Depends(require_permission(PERMISSION_ATTENDANCE_REALTIME_VIEW)), + service: RealtimeAttendanceService = Depends(get_realtime_attendance_service), + operation_logs: OperationLogService = Depends(get_operation_log_service), +) -> RealtimeAttendanceResponse: + """查看某一天的实时考勤看板,通常用于今日打卡状态。""" + response = service.get_today(attendance_date) + operation_logs.record( + actor=current_user, + module="attendance", + action="attendance.realtime.view", + target_type="attendance_date", + target_id=attendance_date.isoformat(), + status="success", + detail=f"查看实时考勤 attendance_date={attendance_date} employee_count={response.summary.employee_total}", + **operation_request_meta(http_request), + ) + return RealtimeAttendanceResponse(**response.to_dict()) + + +@router.post("/sync", response_model=RealtimeAttendanceResponse) +async def sync_realtime_attendance( + http_request: Request, + request: RealtimeAttendanceSyncRequest, + current_user: UserORM = Depends(require_permission(PERMISSION_ATTENDANCE_REALTIME_SYNC)), + service: RealtimeAttendanceService = Depends(get_realtime_attendance_service), + operation_logs: OperationLogService = Depends(get_operation_log_service), +) -> RealtimeAttendanceResponse: + """按员工档案中的钉钉 userId 自动同步本月至今打卡,并刷新今日看板。""" + try: + response = await service.sync_today( + attendance_date=request.attendance_date, + created_by=current_user.username, + include_salary_preview=request.include_salary_preview, + ) + except ValueError as exc: + operation_logs.record( + actor=current_user, + module="attendance", + action="attendance.realtime.sync", + target_type="attendance_sync_job", + target_id="", + status="failed", + detail=f"实时考勤同步失败 attendance_date={request.attendance_date} reason={exc}", + **operation_request_meta(http_request), + ) + logger.error("实时考勤同步参数错误 attendance_date=%s reason=%s", request.attendance_date, exc) + raise HTTPException(status_code=400, detail=str(exc)) from exc + except DingTalkError as exc: + operation_logs.record( + actor=current_user, + module="attendance", + action="attendance.realtime.sync", + target_type="attendance_sync_job", + target_id="", + status="failed", + detail=f"实时考勤钉钉调用失败 attendance_date={request.attendance_date} reason={exc}", + **operation_request_meta(http_request), + ) + logger.error("实时考勤钉钉调用失败 attendance_date=%s reason=%s", request.attendance_date, exc) + raise HTTPException(status_code=502, detail=str(exc)) from exc + + operation_logs.record( + actor=current_user, + module="attendance", + action="attendance.realtime.sync", + target_type="attendance_sync_job", + target_id=response.sync_job_id or "", + status="success", + detail=f"实时考勤同步成功 attendance_date={request.attendance_date} employee_count={response.summary.employee_total}", + **operation_request_meta(http_request), + ) + return RealtimeAttendanceResponse(**response.to_dict()) diff --git a/financial_system/api/schemas/__init__.py b/financial_system/api/schemas/__init__.py index 1f2ae1f..d7ed9c8 100644 --- a/financial_system/api/schemas/__init__.py +++ b/financial_system/api/schemas/__init__.py @@ -14,6 +14,18 @@ from .payroll import ( PayrollJobResponse, PayrollResultDTO, ) +from .monthly_payroll import ( + MonthlyPayrollCreateRequest, + MonthlyPayrollDetailResponse, + MonthlyPayrollRunResponse, + PayrollExceptionItem, +) +from .realtime_attendance import ( + RealtimeAttendanceResponse, + RealtimeAttendanceSummary, + RealtimeAttendanceSyncRequest, + TodayAttendanceItem, +) from .operation_log import OperationLogDTO, OperationLogListResponse from .employee import EmployeeListResponse, EmployeeNoResponse, EmployeeRequest, EmployeeResponse, EmployeeUpdateRequest from .salary_profile import SalaryProfileRequest, SalaryProfileResponse @@ -32,12 +44,20 @@ __all__ = [ "ExcelPayrollResponse", "LoginRequest", "LoginResponse", + "MonthlyPayrollCreateRequest", + "MonthlyPayrollDetailResponse", + "MonthlyPayrollRunResponse", "OperationLogDTO", "OperationLogListResponse", "PayrollJobResponse", + "PayrollExceptionItem", "PayrollResultDTO", + "RealtimeAttendanceResponse", + "RealtimeAttendanceSummary", + "RealtimeAttendanceSyncRequest", "SalaryProfileRequest", "SalaryProfileResponse", + "TodayAttendanceItem", "UserProfileDTO", "UserResponse", ] diff --git a/financial_system/api/schemas/monthly_payroll.py b/financial_system/api/schemas/monthly_payroll.py new file mode 100644 index 0000000..abcc787 --- /dev/null +++ b/financial_system/api/schemas/monthly_payroll.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel + +from .payroll import PayrollResultDTO + + +class MonthlyPayrollCreateRequest(BaseModel): + """月度核算创建请求,用于钉钉计算。""" + + salary_month: str + source_type: str = "dingtalk" + export_excel: bool = True + + +class MonthlyPayrollRunResponse(BaseModel): + """月度核算批次响应。""" + + id: int + salary_month: str + source_type: str + source_job_id: str + status: str + employee_count: int + gross_total: float + net_total: float + deduction_total: float + overtime_total: float + locked_by: str + locked_at: datetime | None + created_by: str + created_at: datetime + updated_at: datetime + + +class PayrollExceptionItem(BaseModel): + """月度核算异常响应。""" + + id: int + run_id: int | None + sync_job_id: str | None + employee_id: int | None + employee_no: str + employee_name: str + salary_month: str + exception_type: str + severity: str + message: str + status: str + created_at: datetime + resolved_at: datetime | None + + +class MonthlyPayrollDetailResponse(BaseModel): + """月度核算批次详情。""" + + run: MonthlyPayrollRunResponse + results: list[PayrollResultDTO] + exceptions: list[PayrollExceptionItem] diff --git a/financial_system/api/schemas/realtime_attendance.py b/financial_system/api/schemas/realtime_attendance.py new file mode 100644 index 0000000..dc7012f --- /dev/null +++ b/financial_system/api/schemas/realtime_attendance.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from datetime import date, datetime + +from pydantic import BaseModel + + +class RealtimeAttendanceSyncRequest(BaseModel): + """实时考勤同步请求。""" + + attendance_date: date + include_salary_preview: bool = True + + +class TodayAttendanceItem(BaseModel): + """实时考勤看板中的员工行。""" + + employee_id: int | None + employee_no: str + employee_name: str + department: str + position: str + first_punch: str + last_punch: str + attendance_status: str + late_minutes: int + missing_card_count: int + leave_hours: float + today_overtime_hours: float + month_remaining_overtime_hours: float + estimated_net_salary: float | None + note: str + + +class RealtimeAttendanceSummary(BaseModel): + """实时考勤看板摘要。""" + + attendance_date: date + employee_total: int + checked_in_count: int + unchecked_count: int + late_count: int + leave_count: int + missing_card_count: int + estimated_overtime_count: int + estimated_net_total: float + + +class RealtimeAttendanceResponse(BaseModel): + """实时考勤看板响应。""" + + sync_job_id: str | None + synced_at: datetime | None + summary: RealtimeAttendanceSummary + items: list[TodayAttendanceItem] diff --git a/financial_system/domain/payroll_exceptions.py b/financial_system/domain/payroll_exceptions.py new file mode 100644 index 0000000..d4c534f --- /dev/null +++ b/financial_system/domain/payroll_exceptions.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from .models import PayrollResult + + +@dataclass(frozen=True) +class PayrollException: + """工资核算异常,供月度核算服务转换为数据库记录。""" + + employee_id: int | None + employee_no: str + employee_name: str + salary_month: str + exception_type: str + severity: str + message: str + + +def detect_payroll_exceptions(results: list[PayrollResult]) -> list[PayrollException]: + """根据工资计算结果识别需要人事或财务复核的异常。""" + exceptions: list[PayrollException] = [] + for result in results: + employee = result.employee + base = { + "employee_id": None, + "employee_no": employee.employee_no, + "employee_name": employee.name, + "salary_month": result.salary_month, + } + if result.base_pay is None: + exceptions.append( + PayrollException( + **base, + exception_type="missing_salary_profile", + severity="error", + message="员工未配置薪资档案,无法计算应发和实发工资", + ) + ) + if result.missing_card_count > 0: + exceptions.append( + PayrollException( + **base, + exception_type="missing_card", + severity="warning", + message=f"缺卡 {result.missing_card_count} 次,已产生扣款 {result.missing_card_deduction:.2f} 元", + ) + ) + if result.penalized_late_count > 0: + exceptions.append( + PayrollException( + **base, + exception_type="late_penalty", + severity="warning", + message=f"迟到扣款 {result.penalized_late_count} 次,扣款 {result.late_deduction:.2f} 元", + ) + ) + if result.remaining_overtime_hours < 0: + exceptions.append( + PayrollException( + **base, + exception_type="negative_remaining_overtime", + severity="warning", + message=f"请假工时超过加班工时,剩余加班 {result.remaining_overtime_hours:.1f} 小时", + ) + ) + if result.attendance_days == 0: + exceptions.append( + PayrollException( + **base, + exception_type="empty_attendance", + severity="error", + message="本月出勤天数为 0,请检查考勤数据或员工状态", + ) + ) + return exceptions diff --git a/financial_system/repositories/__init__.py b/financial_system/repositories/__init__.py index a21bf23..7a8c294 100644 --- a/financial_system/repositories/__init__.py +++ b/financial_system/repositories/__init__.py @@ -2,7 +2,9 @@ from .commission_repository import CommissionRepository from .employee_repository import EmployeeRepository from .operation_log_repository import OperationLogRepository from .organization_repository import OrganizationRepository +from .monthly_payroll_repository import MonthlyPayrollRepository from .payroll_repository import PayrollRepository +from .realtime_attendance_repository import RealtimeAttendanceRepository from .report_repository import ReportRepository from .salary_profile_repository import SalaryProfileRepository from .salary_config_repository import SalaryConfigRepository @@ -13,7 +15,9 @@ __all__ = [ "EmployeeRepository", "OperationLogRepository", "OrganizationRepository", + "MonthlyPayrollRepository", "PayrollRepository", + "RealtimeAttendanceRepository", "ReportRepository", "SalaryConfigRepository", "SalaryProfileRepository", diff --git a/financial_system/repositories/monthly_payroll_repository.py b/financial_system/repositories/monthly_payroll_repository.py new file mode 100644 index 0000000..6a1d7c0 --- /dev/null +++ b/financial_system/repositories/monthly_payroll_repository.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy.orm import Session + +from ..database.orm import MonthlyPayrollRunORM, PayrollExceptionORM + + +class MonthlyPayrollRepository: + """月度核算仓库:维护核算批次和异常清单。""" + + def __init__(self, session: Session): + self.session = session + + def create_run( + self, + *, + salary_month: str, + source_type: str, + source_job_id: str, + created_by: str, + status: str = "calculated", + ) -> MonthlyPayrollRunORM: + existing = self.get_run_by_month(salary_month) + if existing and existing.status == "locked": + raise ValueError("本月工资已锁定,不能重新计算") + if existing is not None: + existing.source_type = source_type + existing.source_job_id = source_job_id + existing.status = status + existing.created_by = created_by + existing.updated_at = datetime.utcnow() + self.session.commit() + self.session.refresh(existing) + return existing + + run = MonthlyPayrollRunORM( + salary_month=salary_month, + source_type=source_type, + source_job_id=source_job_id, + status=status, + created_by=created_by, + ) + self.session.add(run) + self.session.commit() + self.session.refresh(run) + return run + + def get_run(self, run_id: int) -> MonthlyPayrollRunORM | None: + return self.session.query(MonthlyPayrollRunORM).filter(MonthlyPayrollRunORM.id == run_id).one_or_none() + + def require_run(self, run_id: int) -> MonthlyPayrollRunORM: + run = self.get_run(run_id) + if run is None: + raise ValueError("月度核算批次不存在") + return run + + def get_run_by_month(self, salary_month: str) -> MonthlyPayrollRunORM | None: + return ( + self.session.query(MonthlyPayrollRunORM) + .filter(MonthlyPayrollRunORM.salary_month == salary_month) + .order_by(MonthlyPayrollRunORM.updated_at.desc(), MonthlyPayrollRunORM.id.desc()) + .first() + ) + + def list_runs(self, salary_month: str | None = None) -> list[MonthlyPayrollRunORM]: + query = self.session.query(MonthlyPayrollRunORM) + if salary_month: + query = query.filter(MonthlyPayrollRunORM.salary_month == salary_month) + return query.order_by(MonthlyPayrollRunORM.salary_month.desc(), MonthlyPayrollRunORM.id.desc()).all() + + def update_run_totals( + self, + run_id: int, + *, + employee_count: int, + gross_total: float, + net_total: float, + deduction_total: float, + overtime_total: float, + status: str = "calculated", + ) -> MonthlyPayrollRunORM: + run = self.require_run(run_id) + if run.status == "locked": + raise ValueError("本月工资已锁定,不能重新计算") + run.employee_count = employee_count + run.gross_total = gross_total + run.net_total = net_total + run.deduction_total = deduction_total + run.overtime_total = overtime_total + run.status = status + run.updated_at = datetime.utcnow() + self.session.commit() + self.session.refresh(run) + return run + + def lock_run(self, run_id: int, locked_by: str) -> MonthlyPayrollRunORM: + run = self.require_run(run_id) + run.status = "locked" + run.locked_by = locked_by + run.locked_at = datetime.utcnow() + run.updated_at = datetime.utcnow() + self.session.commit() + self.session.refresh(run) + return run + + def replace_exceptions(self, run_id: int, rows: list[PayrollExceptionORM]) -> None: + self.session.query(PayrollExceptionORM).filter(PayrollExceptionORM.run_id == run_id).delete( + synchronize_session=False + ) + if rows: + self.session.add_all(rows) + self.session.commit() + + def list_exceptions(self, run_id: int) -> list[PayrollExceptionORM]: + return ( + self.session.query(PayrollExceptionORM) + .filter(PayrollExceptionORM.run_id == run_id) + .order_by(PayrollExceptionORM.severity.desc(), PayrollExceptionORM.id.asc()) + .all() + ) diff --git a/financial_system/repositories/realtime_attendance_repository.py b/financial_system/repositories/realtime_attendance_repository.py new file mode 100644 index 0000000..7bed393 --- /dev/null +++ b/financial_system/repositories/realtime_attendance_repository.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +from datetime import date, datetime +from uuid import uuid4 + +from sqlalchemy.orm import Session + +from ..database.orm import AttendanceRecordORM, AttendanceSyncJobORM, EmployeeORM, SalaryPreviewORM + + +class RealtimeAttendanceRepository: + """实时考勤仓库:封装同步批次、考勤记录和薪资预估的数据库读写。""" + + def __init__(self, session: Session): + self.session = session + + def list_active_employees(self) -> list[EmployeeORM]: + return ( + self.session.query(EmployeeORM) + .filter(EmployeeORM.employment_status == "active") + .order_by(EmployeeORM.id.asc()) + .all() + ) + + def list_active_employees_with_dingtalk(self) -> list[EmployeeORM]: + return ( + self.session.query(EmployeeORM) + .filter(EmployeeORM.employment_status == "active") + .filter(EmployeeORM.dingtalk_user_id != "") + .order_by(EmployeeORM.id.asc()) + .all() + ) + + def create_sync_job( + self, + *, + source_type: str, + sync_mode: str, + attendance_date: date, + salary_month: str, + start_date: date, + end_date: date, + created_by: str, + ) -> AttendanceSyncJobORM: + job = AttendanceSyncJobORM( + id=uuid4().hex, + source_type=source_type, + sync_mode=sync_mode, + attendance_date=attendance_date, + salary_month=salary_month, + start_date=start_date, + end_date=end_date, + status="running", + created_by=created_by, + ) + self.session.add(job) + self.session.commit() + self.session.refresh(job) + return job + + def mark_sync_completed( + self, + job_id: str, + *, + employee_count: int, + record_count: int, + ) -> AttendanceSyncJobORM: + job = self.require_sync_job(job_id) + job.status = "completed" + job.employee_count = employee_count + job.record_count = record_count + job.error_message = "" + job.updated_at = datetime.utcnow() + self.session.commit() + self.session.refresh(job) + return job + + def mark_sync_failed(self, job_id: str, error_message: str) -> AttendanceSyncJobORM: + job = self.require_sync_job(job_id) + job.status = "failed" + job.error_message = error_message + job.updated_at = datetime.utcnow() + self.session.commit() + self.session.refresh(job) + return job + + def require_sync_job(self, job_id: str) -> AttendanceSyncJobORM: + job = self.session.query(AttendanceSyncJobORM).filter(AttendanceSyncJobORM.id == job_id).one_or_none() + if job is None: + raise ValueError("考勤同步任务不存在") + return job + + def latest_completed_job(self, attendance_date: date) -> AttendanceSyncJobORM | None: + return ( + self.session.query(AttendanceSyncJobORM) + .filter(AttendanceSyncJobORM.attendance_date == attendance_date) + .filter(AttendanceSyncJobORM.status == "completed") + .order_by(AttendanceSyncJobORM.updated_at.desc(), AttendanceSyncJobORM.created_at.desc()) + .first() + ) + + def replace_attendance_records( + self, + *, + source_type: str, + start_date: date, + end_date: date, + employee_ids: list[int], + rows: list[AttendanceRecordORM], + ) -> None: + if employee_ids: + ( + self.session.query(AttendanceRecordORM) + .filter(AttendanceRecordORM.source_type == source_type) + .filter(AttendanceRecordORM.employee_id.in_(employee_ids)) + .filter(AttendanceRecordORM.work_date >= start_date) + .filter(AttendanceRecordORM.work_date <= end_date) + .delete(synchronize_session=False) + ) + if rows: + self.session.add_all(rows) + self.session.commit() + + def replace_salary_previews(self, sync_job_id: str, rows: list[SalaryPreviewORM]) -> None: + self.session.query(SalaryPreviewORM).filter(SalaryPreviewORM.sync_job_id == sync_job_id).delete( + synchronize_session=False + ) + if rows: + self.session.add_all(rows) + self.session.commit() + + def list_today_records(self, attendance_date: date) -> list[AttendanceRecordORM]: + return ( + self.session.query(AttendanceRecordORM) + .filter(AttendanceRecordORM.work_date == attendance_date) + .filter(AttendanceRecordORM.source_type == "dingtalk") + .order_by(AttendanceRecordORM.employee_name.asc(), AttendanceRecordORM.id.asc()) + .all() + ) + + def list_salary_previews(self, salary_month: str) -> list[SalaryPreviewORM]: + return ( + self.session.query(SalaryPreviewORM) + .filter(SalaryPreviewORM.salary_month == salary_month) + .order_by(SalaryPreviewORM.updated_at.desc(), SalaryPreviewORM.id.desc()) + .all() + ) diff --git a/financial_system/services/__init__.py b/financial_system/services/__init__.py index a52fe7a..6fe7a94 100644 --- a/financial_system/services/__init__.py +++ b/financial_system/services/__init__.py @@ -1,9 +1,11 @@ from .auth_service import AuthService, AuthSession from .commission_service import CommissionPage, CommissionService from .employee_service import EmployeePage, EmployeeService +from .monthly_payroll_service import MonthlyPayrollDetailData, MonthlyPayrollService from .operation_log_service import OperationLogPage, OperationLogService from .organization_service import OrganizationService from .payroll_service import PayrollApplicationService, PayrollComputation, PayrollService +from .realtime_attendance_service import RealtimeAttendanceService from .report_service import PayrollReport, ReportService from .salary_config_service import SalaryConfigService from .salary_profile_service import SalaryProfileService @@ -15,6 +17,8 @@ __all__ = [ "CommissionService", "EmployeePage", "EmployeeService", + "MonthlyPayrollDetailData", + "MonthlyPayrollService", "OperationLogPage", "OperationLogService", "OrganizationService", @@ -22,6 +26,7 @@ __all__ = [ "PayrollApplicationService", "PayrollComputation", "PayrollService", + "RealtimeAttendanceService", "ReportService", "SalaryConfigService", "SalaryProfileService", diff --git a/financial_system/services/monthly_payroll_service.py b/financial_system/services/monthly_payroll_service.py new file mode 100644 index 0000000..7ff8e6d --- /dev/null +++ b/financial_system/services/monthly_payroll_service.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +from calendar import monthrange +from dataclasses import dataclass +from datetime import date +from pathlib import Path +from typing import BinaryIO + +from ..core.logger import AppLogger +from ..database.orm import MonthlyPayrollRunORM, PayrollExceptionORM, PayrollJobORM +from ..domain.payroll_exceptions import PayrollException, detect_payroll_exceptions +from ..repositories import EmployeeRepository, MonthlyPayrollRepository +from .payroll_service import PayrollApplicationService, PayrollComputation + +logger = AppLogger.get_logger(__name__) + + +@dataclass(frozen=True) +class MonthlyPayrollDetailData: + run: MonthlyPayrollRunORM + job: PayrollJobORM | None + exceptions: list[PayrollExceptionORM] + + +class MonthlyPayrollService: + """月度核算应用服务:编排正式工资计算、异常识别、锁定和导出。""" + + def __init__( + self, + repository: MonthlyPayrollRepository, + payroll_service: PayrollApplicationService, + employee_repository: EmployeeRepository, + ): + self.repository = repository + self.payroll_service = payroll_service + self.employee_repository = employee_repository + + def list_runs(self, salary_month: str | None = None) -> list[MonthlyPayrollRunORM]: + logger.info("查询月度核算批次 salary_month=%s", salary_month) + return self.repository.list_runs(salary_month) + + def get_detail(self, run_id: int) -> MonthlyPayrollDetailData: + run = self.repository.require_run(run_id) + job = self.payroll_service.get_job(run.source_job_id) if run.source_job_id else None + exceptions = self.repository.list_exceptions(run.id) + logger.info("查询月度核算详情 run_id=%s job_found=%s exception_count=%s", run_id, job is not None, len(exceptions)) + return MonthlyPayrollDetailData(run=run, job=job, exceptions=exceptions) + + def calculate_from_excel( + self, + *, + salary_month: str, + filename: str, + stream: BinaryIO, + created_by: str, + export_excel: bool, + ) -> MonthlyPayrollDetailData: + self._ensure_month_can_calculate(salary_month) + computation = self.payroll_service.calculate_from_excel_upload( + filename=filename, + stream=stream, + config_path=None, + export_excel=export_excel, + ) + run = self._save_calculation( + salary_month=salary_month or self._month_from_computation(computation), + source_type="excel", + created_by=created_by, + computation=computation, + ) + return self.get_detail(run.id) + + async def calculate_from_dingtalk( + self, + *, + salary_month: str, + created_by: str, + export_excel: bool, + ) -> MonthlyPayrollDetailData: + self._ensure_month_can_calculate(salary_month) + employees = [ + employee + for employee in self.employee_repository.list_employees(employment_status="active") + if employee.dingtalk_user_id + ] + if not employees: + raise ValueError("没有绑定钉钉 userId 的在职员工,请先在员工维护中补齐钉钉用户ID") + start_date, end_date = _month_range(salary_month) + computation = await self.payroll_service.calculate_from_dingtalk_realtime( + user_ids=[employee.dingtalk_user_id for employee in employees], + start_date=start_date, + end_date=end_date, + config_path=None, + export_excel=export_excel, + ) + run = self._save_calculation( + salary_month=salary_month, + source_type="dingtalk", + created_by=created_by, + computation=computation, + ) + return self.get_detail(run.id) + + def lock_run(self, run_id: int, locked_by: str) -> MonthlyPayrollRunORM: + logger.info("锁定月度核算 run_id=%s locked_by=%s", run_id, locked_by) + return self.repository.lock_run(run_id, locked_by) + + def export_path(self, run_id: int) -> Path: + detail = self.get_detail(run_id) + if detail.job is None or not detail.job.output_file: + raise FileNotFoundError("该月度核算批次没有可导出的工资表") + path = Path(detail.job.output_file) + if path.exists(): + return path + return self.payroll_service.resolve_output_file(path.name) + + def _save_calculation( + self, + *, + salary_month: str, + source_type: str, + created_by: str, + computation: PayrollComputation, + ) -> MonthlyPayrollRunORM: + run = self.repository.create_run( + salary_month=salary_month, + source_type=source_type, + source_job_id=computation.job_id, + created_by=created_by, + status="calculated", + ) + self.repository.update_run_totals( + run.id, + employee_count=computation.employee_count, + gross_total=round(sum(result.gross_salary or 0 for result in computation.results), 2), + net_total=round(sum(result.net_salary or 0 for result in computation.results), 2), + deduction_total=round(sum(result.total_deduction for result in computation.results), 2), + overtime_total=round(sum(result.remaining_overtime_hours for result in computation.results), 2), + status="calculated", + ) + self.repository.replace_exceptions( + run.id, + self._exception_rows(run.id, detect_payroll_exceptions(computation.results)), + ) + logger.info("月度核算保存完成 run_id=%s job_id=%s", run.id, computation.job_id) + return self.repository.require_run(run.id) + + def _exception_rows( + self, + run_id: int, + exceptions: list[PayrollException], + ) -> list[PayrollExceptionORM]: + rows: list[PayrollExceptionORM] = [] + for item in exceptions: + employee = self.employee_repository.get_by_employee_no(item.employee_no) if item.employee_no else None + rows.append( + PayrollExceptionORM( + run_id=run_id, + employee_id=employee.id if employee else None, + employee_no=item.employee_no, + employee_name=item.employee_name, + salary_month=item.salary_month, + exception_type=item.exception_type, + severity=item.severity, + message=item.message, + status="open", + ) + ) + return rows + + def _ensure_month_can_calculate(self, salary_month: str) -> None: + existing = self.repository.get_run_by_month(salary_month) + if existing and existing.status == "locked": + raise ValueError("本月工资已锁定,不能重新计算") + + def _month_from_computation(self, computation: PayrollComputation) -> str: + for result in computation.results: + if result.salary_month: + return result.salary_month + raise ValueError("无法从工资结果识别工资月份") + + +def _month_range(salary_month: str) -> tuple[date, date]: + year_text, month_text = salary_month.split("-", 1) + year = int(year_text) + month = int(month_text) + return date(year, month, 1), date(year, month, monthrange(year, month)[1]) diff --git a/financial_system/services/payroll_service.py b/financial_system/services/payroll_service.py index fc75ad3..4d67fa5 100644 --- a/financial_system/services/payroll_service.py +++ b/financial_system/services/payroll_service.py @@ -155,6 +155,14 @@ class PayrollApplicationService: logger.info("解析工资结果下载文件 filename=%s", filename) return self.storage.resolve_output_file(filename) + def load_runtime_config(self, config_path: str | None, salary_month: str | None = None) -> AppConfig: + """对外提供当前数据库规则合并后的工资配置。""" + return self._load_runtime_config(config_path, salary_month=salary_month) + + def dingtalk_settings(self) -> DingTalkSettings: + """对外提供钉钉配置读取,统一错误提示。""" + return self._dingtalk_settings() + def _export_if_needed( self, config: AppConfig, diff --git a/financial_system/services/realtime_attendance_service.py b/financial_system/services/realtime_attendance_service.py new file mode 100644 index 0000000..03bfd84 --- /dev/null +++ b/financial_system/services/realtime_attendance_service.py @@ -0,0 +1,395 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass +from datetime import date, datetime, time, timedelta + +from ..core.logger import AppLogger +from ..database.orm import AttendanceRecordORM, EmployeeORM, SalaryPreviewORM +from ..domain.calculator import PayrollCalculator +from ..domain.models import DailyAttendance, EmployeeAttendance, PayrollResult +from ..integrations.dingtalk import DingTalkAttendanceAdapter, DingTalkClient +from ..repositories import RealtimeAttendanceRepository +from .payroll_service import PayrollApplicationService + +logger = AppLogger.get_logger(__name__) + + +@dataclass(frozen=True) +class TodayAttendanceItemData: + employee_id: int | None + employee_no: str + employee_name: str + department: str + position: str + first_punch: str + last_punch: str + attendance_status: str + late_minutes: int + missing_card_count: int + leave_hours: float + today_overtime_hours: float + month_remaining_overtime_hours: float + estimated_net_salary: float | None + note: str + + +@dataclass(frozen=True) +class RealtimeAttendanceSummaryData: + attendance_date: date + employee_total: int + checked_in_count: int + unchecked_count: int + late_count: int + leave_count: int + missing_card_count: int + estimated_overtime_count: int + estimated_net_total: float + + +@dataclass(frozen=True) +class RealtimeAttendanceResponseData: + sync_job_id: str | None + synced_at: datetime | None + summary: RealtimeAttendanceSummaryData + items: list[TodayAttendanceItemData] + + def to_dict(self) -> dict: + return asdict(self) + + +class RealtimeAttendanceService: + """实时考勤应用服务:同步钉钉、落库标准考勤,并生成薪资预估看板。""" + + def __init__( + self, + repository: RealtimeAttendanceRepository, + payroll_service: PayrollApplicationService, + ): + self.repository = repository + self.payroll_service = payroll_service + + def get_today(self, attendance_date: date) -> RealtimeAttendanceResponseData: + logger.info("实时考勤看板查询开始 attendance_date=%s", attendance_date) + employees = self.repository.list_active_employees() + records = self.repository.list_today_records(attendance_date) + previews = self.repository.list_salary_previews(attendance_date.strftime("%Y-%m")) + latest_job = self.repository.latest_completed_job(attendance_date) + items = self._build_today_items(attendance_date, employees, records, previews) + logger.info("实时考勤看板查询完成 attendance_date=%s employee_count=%s", attendance_date, len(items)) + return RealtimeAttendanceResponseData( + sync_job_id=latest_job.id if latest_job else None, + synced_at=latest_job.updated_at if latest_job else None, + summary=build_realtime_summary(attendance_date, items), + items=items, + ) + + async def sync_today( + self, + *, + attendance_date: date, + created_by: str, + include_salary_preview: bool = True, + ) -> RealtimeAttendanceResponseData: + employees = self.repository.list_active_employees_with_dingtalk() + if not employees: + logger.error("实时考勤同步失败 reason=no_dingtalk_user_id") + raise ValueError("没有绑定钉钉 userId 的在职员工,请先在员工维护中补齐钉钉用户ID") + + salary_month = attendance_date.strftime("%Y-%m") + start_date = attendance_date.replace(day=1) + job = self.repository.create_sync_job( + source_type="dingtalk", + sync_mode="month_to_date", + attendance_date=attendance_date, + salary_month=salary_month, + start_date=start_date, + end_date=attendance_date, + created_by=created_by, + ) + logger.info( + "实时考勤同步任务创建 job_id=%s attendance_date=%s employee_count=%s", + job.id, + attendance_date, + len(employees), + ) + + try: + settings = self.payroll_service.dingtalk_settings() + config = self.payroll_service.load_runtime_config(None, salary_month=salary_month) + client = DingTalkClient(settings) + raw_records = await client.list_attendance_records( + [employee.dingtalk_user_id for employee in employees], + datetime.combine(start_date, time.min), + datetime.combine(attendance_date, time(23, 59, 59)), + ) + adapter = DingTalkAttendanceAdapter(config.attendance, timezone=settings.timezone) + adapted_employees = adapter.to_employee_attendance(raw_records) + attendance = self._merge_employee_directory( + employees=employees, + adapted_employees=adapted_employees, + start_date=start_date, + end_date=attendance_date, + ) + results = PayrollCalculator(config).calculate(attendance) + employee_by_no = {employee.employee_no: employee for employee in employees} + employee_ids = [employee.id for employee in employees] + self.repository.replace_attendance_records( + source_type="dingtalk", + start_date=start_date, + end_date=attendance_date, + employee_ids=employee_ids, + rows=self._attendance_rows(results, employee_by_no), + ) + if include_salary_preview: + self.repository.replace_salary_previews( + job.id, + self._salary_preview_rows(job.id, results, employee_by_no), + ) + self.repository.mark_sync_completed( + job.id, + employee_count=len(employees), + record_count=len(raw_records), + ) + logger.info( + "实时考勤同步任务完成 job_id=%s raw_record_count=%s employee_count=%s", + job.id, + len(raw_records), + len(employees), + ) + except Exception as exc: + self.repository.mark_sync_failed(job.id, str(exc)) + logger.exception("实时考勤同步任务失败 job_id=%s", job.id) + raise + + return self.get_today(attendance_date) + + def _merge_employee_directory( + self, + *, + employees: list[EmployeeORM], + adapted_employees: list[EmployeeAttendance], + start_date: date, + end_date: date, + ) -> list[EmployeeAttendance]: + adapted_by_user_id = {employee.user_id: employee for employee in adapted_employees} + days = list(_date_range(start_date, end_date)) + merged: list[EmployeeAttendance] = [] + for employee in employees: + adapted = adapted_by_user_id.get(employee.dingtalk_user_id) + daily_by_date = { + record.work_date: record + for record in adapted.daily_records + } if adapted else {} + merged.append( + EmployeeAttendance( + name=employee.name, + attendance_group="钉钉实时考勤", + department=employee.department, + employee_no=employee.employee_no, + position=employee.position, + user_id=employee.dingtalk_user_id, + daily_records=[daily_by_date.get(day) or DailyAttendance(work_date=day) for day in days], + ) + ) + return merged + + def _build_today_items( + self, + attendance_date: date, + employees: list[EmployeeORM], + records: list[AttendanceRecordORM], + previews: list[SalaryPreviewORM], + ) -> list[TodayAttendanceItemData]: + records_by_employee_id = { + record.employee_id: record + for record in records + if record.employee_id is not None + } + records_by_employee_no = {record.employee_no: record for record in records if record.employee_no} + previews_by_employee_id: dict[int, SalaryPreviewORM] = {} + previews_by_employee_no: dict[str, SalaryPreviewORM] = {} + for preview in previews: + if preview.employee_id is not None and preview.employee_id not in previews_by_employee_id: + previews_by_employee_id[preview.employee_id] = preview + if preview.employee_no and preview.employee_no not in previews_by_employee_no: + previews_by_employee_no[preview.employee_no] = preview + + items = [ + self._today_item_from_employee( + attendance_date, + employee, + records_by_employee_id.get(employee.id) or records_by_employee_no.get(employee.employee_no), + previews_by_employee_id.get(employee.id) or previews_by_employee_no.get(employee.employee_no), + ) + for employee in employees + ] + known_employee_ids = {employee.id for employee in employees} + for record in records: + if record.employee_id in known_employee_ids: + continue + items.append(self._today_item_from_record(record, previews_by_employee_no.get(record.employee_no))) + return items + + def _today_item_from_employee( + self, + attendance_date: date, + employee: EmployeeORM, + record: AttendanceRecordORM | None, + preview: SalaryPreviewORM | None, + ) -> TodayAttendanceItemData: + if record is None: + note = "未绑定钉钉 userId" if not employee.dingtalk_user_id else "" + return TodayAttendanceItemData( + employee_id=employee.id, + employee_no=employee.employee_no, + employee_name=employee.name, + department=employee.department, + position=employee.position, + first_punch="", + last_punch="", + attendance_status="unchecked", + late_minutes=0, + missing_card_count=0, + leave_hours=0, + today_overtime_hours=0, + month_remaining_overtime_hours=preview.remaining_overtime_hours if preview else 0, + estimated_net_salary=preview.estimated_net_salary if preview else None, + note=note, + ) + return self._today_item_from_record(record, preview, fallback_position=employee.position) + + def _today_item_from_record( + self, + record: AttendanceRecordORM, + preview: SalaryPreviewORM | None, + fallback_position: str = "", + ) -> TodayAttendanceItemData: + return TodayAttendanceItemData( + employee_id=record.employee_id, + employee_no=record.employee_no, + employee_name=record.employee_name, + department=record.department, + position=preview.position if preview and preview.position else fallback_position, + first_punch=record.first_punch, + last_punch=record.last_punch, + attendance_status=_attendance_status(record), + late_minutes=record.late_minutes, + missing_card_count=record.missing_card_count, + leave_hours=record.leave_hours, + today_overtime_hours=record.overtime_hours, + month_remaining_overtime_hours=preview.remaining_overtime_hours if preview else 0, + estimated_net_salary=preview.estimated_net_salary if preview else None, + note=preview.note if preview else "", + ) + + def _attendance_rows( + self, + results: list[PayrollResult], + employee_by_no: dict[str, EmployeeORM], + ) -> list[AttendanceRecordORM]: + rows: list[AttendanceRecordORM] = [] + for result in results: + employee = employee_by_no.get(result.employee.employee_no) + for record in result.employee.daily_records: + rows.append( + AttendanceRecordORM( + 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=_daily_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="dingtalk", + ) + ) + return rows + + def _salary_preview_rows( + self, + sync_job_id: str, + results: list[PayrollResult], + employee_by_no: dict[str, EmployeeORM], + ) -> list[SalaryPreviewORM]: + rows: list[SalaryPreviewORM] = [] + for result in results: + employee = employee_by_no.get(result.employee.employee_no) + rows.append( + SalaryPreviewORM( + sync_job_id=sync_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, + position=result.employee.position, + salary_month=result.salary_month, + attendance_days=result.attendance_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, + late_deduction=result.late_deduction, + missing_card_deduction=result.missing_card_deduction, + commission_amount=result.commission_amount, + overtime_pay=result.overtime_pay, + estimated_gross_salary=result.gross_salary, + estimated_net_salary=result.net_salary, + status="warning" if result.note or result.remaining_overtime_hours < 0 else "estimated", + note=result.note, + ) + ) + return rows + + +def build_realtime_summary( + attendance_date: date, + items: list[TodayAttendanceItemData], +) -> RealtimeAttendanceSummaryData: + """按页面行聚合实时考勤摘要,保持统计口径集中可测。""" + checked_in_count = sum(1 for item in items if item.first_punch or item.last_punch) + estimated_net_total = round( + sum(item.estimated_net_salary or 0 for item in items), + 2, + ) + return RealtimeAttendanceSummaryData( + attendance_date=attendance_date, + employee_total=len(items), + checked_in_count=checked_in_count, + unchecked_count=max(0, len(items) - checked_in_count), + late_count=sum(1 for item in items if item.late_minutes > 0), + leave_count=sum(1 for item in items if item.leave_hours > 0), + missing_card_count=sum(item.missing_card_count for item in items), + estimated_overtime_count=sum(1 for item in items if item.today_overtime_hours > 0), + estimated_net_total=estimated_net_total, + ) + + +def _date_range(start_date: date, end_date: date): + current = start_date + while current <= end_date: + yield current + current += timedelta(days=1) + + +def _time_text(value) -> str: + return value.strftime("%H:%M") if value else "" + + +def _daily_status(record: DailyAttendance) -> str: + if record.first_punch or record.last_punch: + return "missing" if record.missing_card_count else "present" + if record.leave_hours_in_cell: + return "leave" + return "unchecked" + + +def _attendance_status(record: AttendanceRecordORM) -> str: + if record.attendance_status == "absent": + return "unchecked" + return record.attendance_status or "unchecked" diff --git a/frontend/README.md b/frontend/README.md index 07eacc6..7c53713 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -19,7 +19,7 @@ | `src/stores/` | Pinia 状态管理,目前主要是登录态、菜单、权限和主题色。 | | `src/router/` | 路由配置和登录/权限守卫。 | | `src/components/` | 通用布局、侧边栏、顶部栏、统计卡片和工资结果表格。 | -| `src/views/` | 登录、工作台、工资计算、计算记录、提成管理、统计报表、组织架构、员工维护、薪资维护、规则配置、用户管理、操作日志页面。 | +| `src/views/` | 登录、工作台、实时考勤、月度核算、工资计算、计算记录、提成管理、统计报表、组织架构、员工维护、薪资维护、规则配置、用户管理、操作日志页面。 | | `src/assets/` | 全局样式和 UI 设计变量。 | ## 本地启动 @@ -55,7 +55,7 @@ http://127.0.0.1:5173/ /static -> http://127.0.0.1:8000 ``` -因此前端代码中可以直接请求 `/api/auth/login`、`/api/payroll/excel` 等路径,头像静态资源也可以直接访问 `/static/uploads/avatars/...`。 +因此前端代码中可以直接请求 `/api/auth/login`、`/api/attendance/realtime/today`、`/api/payroll/monthly/runs`、`/api/payroll/excel` 等路径,头像静态资源也可以直接访问 `/static/uploads/avatars/...`。 如果部署时前端和后端不在同一个域名,可以复制 `.env.example` 为 `.env`,并设置: @@ -90,6 +90,8 @@ VITE_DEV_PROXY_TARGET=http://127.0.0.1:8001 | --- | --- | --- | | 登录 | `/login` | 用户登录入口。 | | 工作台 | `/dashboard` | 展示最近计算任务、统计卡片和趋势面板。 | +| 实时考勤 | `/attendance/realtime` | 查看今日打卡、迟到、缺卡、请假、加班和本月薪资预估,可同步钉钉刷新。 | +| 月度核算 | `/payroll/monthly` | 按工资月份完成 Excel 导入、钉钉同步计算、异常处理、锁定和导出。 | | 工资计算 | `/payroll` | 支持 Excel 导入和钉钉实时计算。 | | 计算记录 | `/payroll/jobs` | 支持按任务编号查询已落库工资结果。 | | 提成管理 | `/payroll/commissions` | 支持手工维护提成和 Excel 批量导入。 | @@ -105,6 +107,8 @@ VITE_DEV_PROXY_TARGET=http://127.0.0.1:8001 ## 个性化与个人资料 +实时考勤中的薪资金额是过程预估,最终工资以月度核算锁定结果为准。月度核算锁定后,本月工资不能重新计算,只保留导出。 + 顶部栏提供两个常用入口: - 调色盘按钮:切换主题色,支持深海蓝、专业蓝、翡翠绿、勃艮第,选择结果保存在浏览器本地。 @@ -121,8 +125,8 @@ static/uploads/avatars | 角色 | 可见菜单和操作 | | --- | --- | | `superuser` | 拥有全部菜单和全部操作权限。 | -| `manager` | 可进行工资计算、组织架构、员工维护、薪资维护、提成管理、规则配置、报表查看和操作日志查看。 | -| `viewer` | 可查看计算记录、下载结果和统计报表。 | +| `manager` | 可进行实时考勤同步、月度核算、工资计算、组织架构、员工维护、薪资维护、提成管理、规则配置、报表查看和操作日志查看。 | +| `viewer` | 可查看实时考勤、计算记录、下载结果和统计报表。 | ## 构建 diff --git a/frontend/src/api/monthlyPayroll.ts b/frontend/src/api/monthlyPayroll.ts new file mode 100644 index 0000000..781b2dc --- /dev/null +++ b/frontend/src/api/monthlyPayroll.ts @@ -0,0 +1,68 @@ +import { apiFetch, buildApiUrl, getStoredToken } from "./http"; +import type { MonthlyPayrollDetailResponse, MonthlyPayrollRun } from "./types"; + +export function listMonthlyRuns(month?: string): Promise { + const query = month ? `?month=${encodeURIComponent(month)}` : ""; + return apiFetch(`/api/payroll/monthly/runs${query}`); +} + +export function getMonthlyRun(runId: number): Promise { + return apiFetch(`/api/payroll/monthly/runs/${runId}`); +} + +export function calculateMonthlyFromExcel(file: File, salaryMonth: string): Promise { + const formData = new FormData(); + formData.append("file", file); + formData.append("salary_month", salaryMonth); + formData.append("export_excel", "true"); + return apiFetch("/api/payroll/monthly/excel", { + method: "POST", + body: formData, + }); +} + +export function calculateMonthlyFromDingTalk(salaryMonth: string): Promise { + return apiFetch("/api/payroll/monthly/dingtalk", { + method: "POST", + body: JSON.stringify({ + salary_month: salaryMonth, + source_type: "dingtalk", + export_excel: true, + }), + }); +} + +export function recalculateMonthlyRun(runId: number): Promise { + return apiFetch(`/api/payroll/monthly/runs/${runId}/recalculate`, { + method: "POST", + }); +} + +export function lockMonthlyRun(runId: number): Promise { + return apiFetch(`/api/payroll/monthly/runs/${runId}/lock`, { + method: "POST", + }); +} + +export async function exportMonthlyRun(runId: number): Promise { + const headers = new Headers(); + const token = getStoredToken(); + if (token) { + headers.set("Authorization", `Bearer ${token}`); + } + + const response = await fetch(buildApiUrl(`/api/payroll/monthly/runs/${runId}/export`), { headers }); + if (!response.ok) { + throw new Error("月度工资表导出失败"); + } + + const blob = await response.blob(); + const objectUrl = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = objectUrl; + anchor.download = `月度工资核算-${runId}.xlsx`; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(objectUrl); +} diff --git a/frontend/src/api/realtimeAttendance.ts b/frontend/src/api/realtimeAttendance.ts new file mode 100644 index 0000000..35eee21 --- /dev/null +++ b/frontend/src/api/realtimeAttendance.ts @@ -0,0 +1,16 @@ +import { apiFetch } from "./http"; +import type { RealtimeAttendanceResponse } from "./types"; + +export function getTodayAttendance(date: string): Promise { + return apiFetch(`/api/attendance/realtime/today?date=${encodeURIComponent(date)}`); +} + +export function syncRealtimeAttendance(date: string): Promise { + return apiFetch("/api/attendance/realtime/sync", { + method: "POST", + body: JSON.stringify({ + attendance_date: date, + include_salary_preview: true, + }), + }); +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 642c69a..9991cc3 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -376,3 +376,80 @@ export interface PayrollReportResponse { created_at: string; }>; } + +export interface RealtimeAttendanceSummary { + attendance_date: string; + employee_total: number; + checked_in_count: number; + unchecked_count: number; + late_count: number; + leave_count: number; + missing_card_count: number; + estimated_overtime_count: number; + estimated_net_total: number; +} + +export interface TodayAttendanceItem { + employee_id: number | null; + employee_no: string; + employee_name: string; + department: string; + position: string; + first_punch: string; + last_punch: string; + attendance_status: string; + late_minutes: number; + missing_card_count: number; + leave_hours: number; + today_overtime_hours: number; + month_remaining_overtime_hours: number; + estimated_net_salary: number | null; + note: string; +} + +export interface RealtimeAttendanceResponse { + sync_job_id: string | null; + synced_at: string | null; + summary: RealtimeAttendanceSummary; + items: TodayAttendanceItem[]; +} + +export interface MonthlyPayrollRun { + id: number; + salary_month: string; + source_type: string; + source_job_id: string; + status: "draft" | "calculated" | "reviewed" | "locked" | "failed" | string; + employee_count: number; + gross_total: number; + net_total: number; + deduction_total: number; + overtime_total: number; + locked_by: string; + locked_at: string | null; + created_by: string; + created_at: string; + updated_at: string; +} + +export interface PayrollException { + id: number; + run_id: number | null; + sync_job_id: string | null; + employee_id: number | null; + employee_no: string; + employee_name: string; + salary_month: string; + exception_type: string; + severity: string; + message: string; + status: string; + created_at: string; + resolved_at: string | null; +} + +export interface MonthlyPayrollDetailResponse { + run: MonthlyPayrollRun; + results: PayrollResult[]; + exceptions: PayrollException[]; +} diff --git a/frontend/src/assets/styles.css b/frontend/src/assets/styles.css index 532a21a..446746c 100644 --- a/frontend/src/assets/styles.css +++ b/frontend/src/assets/styles.css @@ -1,50 +1,50 @@ :root { - --surface: #f7f9fb; - --surface-soft: #eef2f6; - --surface-strong: #e3e8ee; + --surface: #f5f7fa; + --surface-soft: #eef2f7; + --surface-strong: #d9e1ec; --card: #ffffff; - --primary: #041627; - --primary-soft: #1a2b3c; - --text: #191c1e; - --muted: #5f6670; - --line: #d8dee6; - --line-soft: #edf1f5; - --blue: #0f62c6; + --primary: #22313f; + --primary-soft: #d3d7db; + --text: #1f2933; + --muted: #667381; + --line: #c7d0dc; + --line-soft: #f4f7fa; + --blue: #2563eb; --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; + --sidebar-bg: #ffffff; + --nav-text: #334155; + --nav-active-bg: #e8f0ff; + --topbar-bg: rgba(245, 247, 250, 0.94); + --search-bg: #f6f8fb; + --control-bg: #ffffff; + --secondary-bg: #eef2f7; + --secondary-hover-bg: #d9e1ec; + --secondary-hover-border: #acb8c7; + --table-header-bg: #f5f8fb; + --table-row-hover-bg: #f9fbff; + --modal-backdrop: rgba(34, 49, 63, 0.32); + --primary-rgb: 34, 49, 63; + --accent-rgb: 37, 99, 235; --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; + --radius-sm: 5px; + --radius-md: 7px; + --radius-lg: 10px; + --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; --layout-sidebar-width: 280px; --layout-sidebar-offset: 280px; --collapsed-sidebar-width: 88px; @@ -561,7 +561,7 @@ a { } .appearance-panel { - width: min(560px, calc(100vw - 20px)); + width: min(480px, calc(100vw - 20px)); max-height: 100vh; margin: 0; overflow-y: auto; @@ -570,7 +570,7 @@ a { background: var(--card); box-shadow: 0 28px 70px rgba(var(--primary-rgb), 0.26); color: var(--text); - padding: 16px; + padding: 14px; transform: translateX(0); transition: opacity var(--motion-duration-fast) ease, @@ -628,8 +628,19 @@ a { display: flex; align-items: flex-start; justify-content: space-between; - gap: 12px; - margin-bottom: 14px; + gap: 10px; + margin-bottom: 12px; +} + +.appearance-header-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.appearance-recommend { + min-height: 30px; + padding-inline: 9px; } .appearance-panel-header .icon-button { @@ -649,7 +660,7 @@ a { } .appearance-panel-header h2 { - font-size: 18px; + font-size: 17px; } .appearance-panel-header p { @@ -664,7 +675,7 @@ a { } .appearance-section + .appearance-section { - margin-top: 16px; + margin-top: 14px; } .appearance-section-head { @@ -674,7 +685,7 @@ a { } .appearance-section-head h3 { - font-size: 16px; + font-size: 14px; } .appearance-reset { @@ -701,7 +712,7 @@ a { .appearance-grid { display: grid; - gap: 10px; + gap: 8px; } .mode-grid, @@ -712,7 +723,7 @@ a { } .color-preset-grid { - grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-columns: repeat(3, minmax(0, 1fr)); } .radius-grid { @@ -734,7 +745,7 @@ a { gap: 6px; min-width: 0; border: 1px solid var(--line); - border-radius: var(--radius-lg); + border-radius: var(--radius-md); background: var(--surface); color: var(--text); padding: 6px; @@ -757,7 +768,7 @@ a { .appearance-choice strong, .color-preset-choice strong { color: var(--primary); - font-size: 13px; + font-size: 12px; line-height: 1.25; } @@ -800,7 +811,7 @@ a { } .mode-preview { - height: 78px; + height: 66px; border: 1px solid var(--line); } @@ -916,26 +927,102 @@ a { } .color-preset-choice { - min-height: 68px; + min-height: 74px; align-content: start; - background: transparent; - border-color: transparent; - padding: 0; + border-color: var(--line); + background: var(--card); + padding: 6px; } .color-preset-swatch { + position: relative; display: block; - height: 42px; + height: 44px; + overflow: hidden; 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); + border-radius: var(--radius-md); + background: var(--preset-surface); } .color-preset-choice strong { text-align: center; } +.preset-sidebar { + position: absolute; + inset: 0 auto 0 0; + display: grid; + width: 34%; + align-content: start; + gap: 4px; + background: var(--preset-sidebar); + padding: 7px 6px; +} + +.preset-sidebar b, +.preset-main b { + display: block; + height: 4px; + border-radius: 999px; +} + +.preset-sidebar b { + background: var(--preset-primary); + opacity: 0.7; +} + +.preset-sidebar b:first-child { + width: 18px; +} + +.preset-sidebar b:nth-child(2) { + width: 30px; + background: var(--preset-active); + opacity: 1; +} + +.preset-sidebar b:nth-child(3) { + width: 22px; +} + +.preset-main { + position: absolute; + inset: 6px 6px 6px 42%; + display: grid; + align-content: start; + gap: 5px; + border-radius: var(--radius-sm); + background: var(--preset-card); + padding: 7px; +} + +.preset-main b { + background: var(--preset-primary); + opacity: 0.22; +} + +.preset-main b:first-child { + width: 68%; +} + +.preset-main b:nth-child(2) { + width: 88%; +} + +.preset-main b:nth-child(3) { + width: 52%; +} + +.preset-accent { + position: absolute; + right: 10px; + bottom: 10px; + width: 22px; + height: 5px; + border-radius: 999px; + background: var(--preset-accent); +} + .font-choice { min-height: 54px; place-items: center; @@ -1748,6 +1835,293 @@ a { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.compact-field { + min-width: 170px; +} + +.compact-field span { + font-size: 12px; +} + +.compact-stats-grid { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 10px; + padding: 0 var(--panel-padding) var(--panel-padding); +} + +.compact-stat { + min-width: 0; + border: 1px solid var(--line); + border-radius: var(--radius-md); + background: var(--surface); + padding: 12px 14px; +} + +.compact-stat span, +.workflow-steps span { + color: var(--muted); + font-size: 12px; +} + +.compact-stat strong { + display: block; + margin-top: 6px; + color: var(--primary); + font-size: 19px; + line-height: 1.2; +} + +.organization-summary-strip { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + border: 1px solid var(--line); + border-radius: var(--radius-lg); + background: var(--card); + box-shadow: 0 8px 20px rgba(var(--primary-rgb), 0.04); + padding: 10px; +} + +.organization-summary-strip div { + display: grid; + grid-template-columns: auto auto minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + min-height: 50px; + border-radius: var(--radius-md); + background: var(--surface); + padding: 10px 12px; +} + +.organization-summary-strip svg { + color: var(--blue); +} + +.organization-summary-strip span, +.organization-summary-strip small { + color: var(--muted); + font-size: 12px; + font-weight: 800; +} + +.organization-summary-strip strong { + min-width: 0; + overflow: hidden; + color: var(--primary); + font-size: 18px; + line-height: 1.15; + text-overflow: ellipsis; + white-space: nowrap; +} + +.organization-tree-layout { + display: grid; + grid-template-columns: minmax(340px, 0.9fr) minmax(520px, 1.45fr); + gap: var(--content-gap); + align-items: start; +} + +.organization-tree-panel, +.organization-detail-panel { + min-height: 620px; +} + +.organization-tree-tools, +.organization-detail-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + flex-wrap: wrap; +} + +.link-button.muted { + color: var(--muted); +} + +.compact-button { + min-height: 36px; + padding: 0 12px; + font-size: 13px; +} + +.organization-tree { + display: grid; + align-content: start; + gap: 4px; + max-height: calc(100vh - 330px); + min-height: 520px; + overflow: auto; + padding: 12px; +} + +.organization-tree-empty { + display: flex; + min-height: 240px; + align-items: center; + justify-content: center; + gap: 8px; + color: var(--muted); + font-weight: 800; +} + +.tree-node { + display: grid; + grid-template-columns: 22px 32px minmax(0, 1fr) auto; + width: 100%; + min-height: 50px; + align-items: center; + gap: 8px; + border: 0; + border-radius: var(--radius-md); + background: transparent; + color: var(--text); + padding: 7px 10px 7px calc(10px + (var(--tree-depth) * 20px)); + text-align: left; + transition: + background 0.18s ease, + box-shadow 0.18s ease, + color 0.18s ease, + opacity 0.18s ease; +} + +.tree-node:hover { + background: var(--surface); +} + +.tree-node.is-selected { + background: var(--blue-soft); + box-shadow: inset 3px 0 0 var(--blue); +} + +.tree-node.is-inactive { + opacity: 0.66; +} + +.tree-toggle { + display: grid; + width: 22px; + height: 22px; + place-items: center; + border-radius: var(--radius-sm); + color: var(--muted); +} + +.tree-toggle:not(.is-placeholder):hover { + background: rgba(var(--accent-rgb), 0.1); + color: var(--blue); +} + +.tree-toggle.is-placeholder { + visibility: hidden; +} + +.tree-node-icon { + display: grid; + width: 32px; + height: 32px; + place-items: center; + border-radius: var(--radius-sm); + background: var(--surface-soft); + color: var(--primary); +} + +.tree-node-position .tree-node-icon { + background: var(--green-soft); + color: var(--green); +} + +.tree-node-main { + display: grid; + gap: 3px; + min-width: 0; +} + +.tree-node-main strong, +.tree-node-main small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tree-node-main strong { + color: var(--primary); + font-size: 14px; + line-height: 1.2; +} + +.tree-node-main small { + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.tree-node .status-chip { + min-height: 22px; + padding: 0 8px; + font-size: 11px; +} + +.organization-detail-header { + align-items: flex-start; +} + +.organization-node-summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + border-bottom: 1px solid var(--line-soft); + background: var(--surface); + padding: 12px var(--panel-padding); +} + +.organization-node-summary div { + min-width: 0; + border: 1px solid var(--line-soft); + border-radius: var(--radius-md); + background: var(--card); + padding: 10px 12px; +} + +.organization-node-summary span { + display: block; + color: var(--muted); + font-size: 12px; + font-weight: 800; +} + +.organization-node-summary strong { + display: block; + min-width: 0; + overflow: hidden; + margin-top: 5px; + color: var(--primary); + font-size: 14px; + line-height: 1.3; + text-overflow: ellipsis; + white-space: nowrap; +} + +.organization-editor-form { + padding-top: 18px; +} + +.workflow-steps { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 8px; + padding: 0 var(--panel-padding) var(--panel-padding); +} + +.workflow-steps span { + border: 1px solid var(--line); + border-radius: var(--radius-md); + background: var(--surface); + padding: 9px 10px; + text-align: center; +} + .user-form { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -2049,14 +2423,14 @@ a { .login-screen { display: grid; min-height: 100vh; - grid-template-columns: minmax(420px, 42vw) minmax(0, 1fr); + grid-template-columns: minmax(400px, 40vw) minmax(0, 1fr); background: var(--surface); } .login-form-side { display: grid; place-items: center; - padding: 48px; + padding: 42px 48px; } .login-card { @@ -2064,13 +2438,13 @@ a { } .login-brand { - margin-bottom: 28px; + margin-bottom: 24px; } .login-brand h1 { - margin: 22px 0 8px; + margin: 18px 0 7px; color: var(--primary); - font-size: 28px; + font-size: 26px; line-height: 1.2; } @@ -2081,7 +2455,7 @@ a { .form-stack { display: grid; - gap: 18px; + gap: 16px; } .input-with-icon { @@ -2118,9 +2492,9 @@ a { display: flex; flex-wrap: wrap; gap: 14px 22px; - margin-top: 42px; + margin-top: 34px; border-top: 1px solid var(--line); - padding-top: 24px; + padding-top: 20px; color: var(--muted); } @@ -2139,43 +2513,42 @@ a { 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; + border-left: 1px solid var(--line); + background: var(--surface-soft); + color: var(--text); + padding: 44px; } .ledger-visual { - width: min(620px, 100%); - border: 1px solid rgba(255, 255, 255, 0.16); + width: min(560px, 100%); + border: 1px solid var(--line); 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); + background: var(--card); + padding: 32px; + box-shadow: var(--shadow); } .ledger-visual h2 { - margin: 24px 0 18px; - font-size: 44px; - line-height: 1.18; + margin: 22px 0 14px; + color: var(--primary); + font-size: 31px; + line-height: 1.25; } .ledger-visual p { margin: 0; - color: rgba(255, 255, 255, 0.82); - font-size: 17px; - line-height: 1.7; + color: var(--muted); + font-size: 15px; + line-height: 1.65; } .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; + gap: 18px; + margin-top: 28px; + border-top: 1px solid var(--line); + padding-top: 22px; } .visual-metrics strong, @@ -2184,12 +2557,13 @@ a { } .visual-metrics strong { - font-size: 32px; + color: var(--primary); + font-size: 25px; } .visual-metrics span { margin-top: 4px; - color: rgba(255, 255, 255, 0.68); + color: var(--muted); } .security-line { @@ -2199,7 +2573,7 @@ a { display: inline-flex; align-items: center; gap: 8px; - color: rgba(255, 255, 255, 0.58); + color: var(--muted); font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace; font-size: 13px; } @@ -2212,7 +2586,7 @@ a { @media (max-width: 1120px) { .appearance-panel { - width: min(540px, calc(100vw - 16px)); + width: min(480px, calc(100vw - 16px)); padding: 14px; } @@ -2228,11 +2602,17 @@ a { .stats-grid, .dashboard-grid, .action-grid, + .compact-stats-grid, .form-grid, + .workflow-steps, .password-form { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .organization-tree-layout { + grid-template-columns: 1fr; + } + .login-screen { grid-template-columns: 1fr; } @@ -2344,14 +2724,17 @@ a { } .stats-grid, + .organization-summary-strip, .dashboard-grid, .action-grid, + .compact-stats-grid, .form-grid, .user-form, .password-form, .search-form, .table-filter-bar, - .visual-metrics { + .visual-metrics, + .workflow-steps { grid-template-columns: 1fr; } @@ -2379,6 +2762,24 @@ a { .page-header h1 { font-size: 27px; } + + .organization-tree { + max-height: none; + min-height: 360px; + } + + .organization-tree-panel, + .organization-detail-panel { + min-height: auto; + } + + .organization-detail-actions { + justify-content: flex-start; + } + + .organization-node-summary { + grid-template-columns: 1fr; + } } @media (prefers-reduced-motion: reduce) { diff --git a/frontend/src/components/AppearancePanel.vue b/frontend/src/components/AppearancePanel.vue index acedbe0..76f0d41 100644 --- a/frontend/src/components/AppearancePanel.vue +++ b/frontend/src/components/AppearancePanel.vue @@ -26,13 +26,13 @@ const modeOptions: Array<{ name: ThemeMode; label: string }> = [ ]; const fontOptions: Array<{ name: FontChoice; label: string }> = [ - { name: "auto", label: "Auto" }, - { name: "sans", label: "Sans" }, - { name: "serif", label: "Serif" }, + { name: "auto", label: "系统" }, + { name: "sans", label: "现代" }, + { name: "serif", label: "宋体" }, ]; const radiusOptions: Array<{ name: RadiusChoice; label: string; previewRadius: string }> = [ - { name: "auto", label: "Auto", previewRadius: "20px" }, + { name: "auto", label: "默认", previewRadius: "20px" }, { name: "0", label: "0", previewRadius: "0px" }, { name: "0.3", label: "0.3", previewRadius: "8px" }, { name: "0.5", label: "0.5", previewRadius: "14px" }, @@ -56,7 +56,7 @@ const sidebarOptions: Array<{ name: SidebarChoice; label: string }> = [ const layoutOptions: Array<{ name: LayoutChoice; label: string }> = [ { name: "default", label: "默认" }, { name: "compact", label: "紧凑" }, - { name: "fullscreen", label: "全屏布局" }, + { name: "fullscreen", label: "全屏" }, ]; const contentWidthOptions: Array<{ name: ContentWidthChoice; label: string }> = [ @@ -70,12 +70,18 @@ const contentWidthOptions: Array<{ name: ContentWidthChoice; label: string }> =