diff --git a/.env b/.env index 71bb558..06a9411 100644 --- a/.env +++ b/.env @@ -5,5 +5,5 @@ # If MySQL runs on another server, use that server's reachable IP address or domain name. DATABASE_URL=mysql+pymysql://root:Plo6lvzOPtMNzVIA@host.docker.internal:3306/financial_system?charset=utf8mb4 -BACKEND_PORT=8000 -FRONTEND_PORT=5173 +BACKEND_PORT=8005 +FRONTEND_PORT=5179 diff --git a/Dockerfile b/Dockerfile index 1450702..5e31e9c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.12-slim +FROM docker.m.daocloud.io/library/python:3.12-slim ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 diff --git a/README.md b/README.md index 0d8ccf9..c382f15 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,7 @@ config/app_settings.json | --- | --- | | `server.host` / `server.port` | 后端监听地址和端口 | | `database.url` | 数据库连接 | +| 系统时区 | 后端统一使用 `Asia/Shanghai`,MySQL 会话启动时自动设置为 `+08:00` | | `dingtalk.app_key` / `dingtalk.app_secret` | 钉钉开放平台凭证 | | `storage.upload_dir` | 上传文件目录 | | `storage.output_dir` | 导出工资文件目录 | @@ -187,6 +188,12 @@ mysql -h localhost -P 3306 -u root -p12345678 financial_system < financial_syste mysql -h localhost -P 3306 -u root -p12345678 financial_system < financial_system/database/sql/upgrade_20260618_monthly_payroll_center.sql ``` +如果历史页面时间整体少 8 小时,说明旧数据曾按 UTC 写入,可确认后执行一次修正脚本: + +```bash +mysql -h localhost -P 3306 -u root -p12345678 financial_system < financial_system/database/sql/upgrade_20260623_timezone_shanghai.sql +``` + SQL 文件说明: | 文件 | 说明 | @@ -196,6 +203,7 @@ SQL 文件说明: | `init_mysql.sql` | 一键初始化脚本,包含建库、建表、默认配置、默认管理员 | | `upgrade_20260617_payroll_modules.sql` | 当前薪酬考勤完整模块升级脚本 | | `upgrade_20260618_monthly_payroll_center.sql` | 实时考勤同步、薪资预估、月度核算批次和异常清单升级脚本 | +| `upgrade_20260623_timezone_shanghai.sql` | 一次性修正历史 UTC 时间为北京时间,确认整体少 8 小时后再执行 | 主要业务表: diff --git a/financial_system/api/dependencies.py b/financial_system/api/dependencies.py index 8c276b4..08b08b9 100644 --- a/financial_system/api/dependencies.py +++ b/financial_system/api/dependencies.py @@ -162,6 +162,7 @@ def get_payroll_service( repository: PayrollRepository = Depends(get_payroll_repository), salary_repository: SalaryProfileRepository = Depends(get_salary_profile_repository), commission_repository: CommissionRepository = Depends(get_commission_repository), + employee_repository: EmployeeRepository = Depends(get_employee_repository), config_repository: SalaryConfigRepository = Depends(get_salary_config_repository), settings: AppSettings = Depends(get_app_settings), ) -> PayrollApplicationService: @@ -171,6 +172,7 @@ def get_payroll_service( salary_repository=salary_repository, commission_repository=commission_repository, config_service=SalaryConfigService(config_repository), + employee_repository=employee_repository, ) diff --git a/financial_system/api/routers/employees.py b/financial_system/api/routers/employees.py index a57ff43..628f16b 100644 --- a/financial_system/api/routers/employees.py +++ b/financial_system/api/routers/employees.py @@ -40,6 +40,8 @@ def preview_next_employee_no( def list_employees( http_request: Request, keyword: str | None = Query(default=None, description="员工编号、姓名、部门、岗位等关键字"), + employee_keyword: str | None = Query(default=None, description="员工信息关键字:编号、姓名、钉钉用户ID或手机号"), + organization_keyword: str | None = Query(default=None, description="组织信息关键字:部门或岗位"), employment_status: str | None = Query(default=None, description="员工状态:active或inactive"), current_user: UserORM = Depends(require_permission(PERMISSION_EMPLOYEE_VIEW)), service: EmployeeService = Depends(get_employee_service), @@ -47,7 +49,12 @@ def list_employees( ) -> list[EmployeeResponse]: """查询员工档案。""" try: - employees = service.list_employees(keyword=keyword, employment_status=employment_status) + employees = service.list_employees( + keyword=keyword, + employee_keyword=employee_keyword, + organization_keyword=organization_keyword, + employment_status=employment_status, + ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc operation_logs.record( @@ -69,6 +76,8 @@ def list_employees_page( page: int = Query(default=1, ge=1, description="页码,从1开始"), page_size: int = Query(default=10, ge=1, le=100, description="每页条数,最大100"), keyword: str | None = Query(default=None, description="员工编号、姓名、部门、岗位等关键字"), + employee_keyword: str | None = Query(default=None, description="员工信息关键字:编号、姓名、钉钉用户ID或手机号"), + organization_keyword: str | None = Query(default=None, description="组织信息关键字:部门或岗位"), employment_status: str | None = Query(default=None, description="员工状态:active或inactive"), current_user: UserORM = Depends(require_permission(PERMISSION_EMPLOYEE_VIEW)), service: EmployeeService = Depends(get_employee_service), @@ -80,6 +89,8 @@ def list_employees_page( page=page, page_size=page_size, keyword=keyword, + employee_keyword=employee_keyword, + organization_keyword=organization_keyword, employment_status=employment_status, ) except ValueError as exc: diff --git a/financial_system/core/permissions.py b/financial_system/core/permissions.py index f3c389c..3c05849 100644 --- a/financial_system/core/permissions.py +++ b/financial_system/core/permissions.py @@ -62,8 +62,6 @@ ROLE_MENU_CODES = { ROLE_SUPERUSER: ( "attendance_realtime", "monthly_payroll", - "payroll", - "jobs", "employees", "organization", "salary_profiles", @@ -76,8 +74,6 @@ ROLE_MENU_CODES = { ROLE_MANAGER: ( "attendance_realtime", "monthly_payroll", - "payroll", - "jobs", "employees", "organization", "salary_profiles", @@ -86,7 +82,7 @@ ROLE_MENU_CODES = { "configs", "operation_logs", ), - ROLE_VIEWER: ("attendance_realtime", "jobs", "reports"), + ROLE_VIEWER: ("attendance_realtime", "monthly_payroll", "reports"), } ROLE_OPERATION_PERMISSIONS = { diff --git a/financial_system/core/timezone.py b/financial_system/core/timezone.py new file mode 100644 index 0000000..db65347 --- /dev/null +++ b/financial_system/core/timezone.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from datetime import datetime +from zoneinfo import ZoneInfo + +SYSTEM_TIMEZONE = "Asia/Shanghai" + + +def now_shanghai() -> datetime: + """返回系统统一使用的北京时间,保存到 DATETIME 前去掉 tzinfo。""" + return datetime.now(ZoneInfo(SYSTEM_TIMEZONE)).replace(tzinfo=None) + diff --git a/financial_system/database/orm.py b/financial_system/database/orm.py index d74cd21..d1bb4b7 100644 --- a/financial_system/database/orm.py +++ b/financial_system/database/orm.py @@ -5,6 +5,8 @@ from datetime import date, datetime from sqlalchemy import Boolean, Date, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship +from ..core.timezone import now_shanghai + class Base(DeclarativeBase): pass @@ -84,14 +86,14 @@ class UserORM(Base): created_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, - default=datetime.utcnow, + default=now_shanghai, comment="创建时间", ) updated_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, - default=datetime.utcnow, - onupdate=datetime.utcnow, + default=now_shanghai, + onupdate=now_shanghai, comment="更新时间", ) @@ -124,12 +126,12 @@ class DepartmentORM(Base): sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="排序号,越小越靠前") is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True, comment="是否启用") remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="部门备注") - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间") + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=now_shanghai, comment="创建时间") updated_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, - default=datetime.utcnow, - onupdate=datetime.utcnow, + default=now_shanghai, + onupdate=now_shanghai, comment="更新时间", ) @@ -165,12 +167,12 @@ class PositionORM(Base): sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="排序号,越小越靠前") is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True, comment="是否启用") remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="岗位备注") - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间") + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=now_shanghai, comment="创建时间") updated_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, - default=datetime.utcnow, - onupdate=datetime.utcnow, + default=now_shanghai, + onupdate=now_shanghai, comment="更新时间", ) @@ -217,14 +219,14 @@ class EmployeeORM(Base): created_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, - default=datetime.utcnow, + default=now_shanghai, comment="创建时间", ) updated_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, - default=datetime.utcnow, - onupdate=datetime.utcnow, + default=now_shanghai, + onupdate=now_shanghai, comment="更新时间", ) @@ -306,14 +308,14 @@ class SalaryProfileORM(Base): created_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, - default=datetime.utcnow, + default=now_shanghai, comment="创建时间", ) updated_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, - default=datetime.utcnow, - onupdate=datetime.utcnow, + default=now_shanghai, + onupdate=now_shanghai, comment="更新时间", ) @@ -362,7 +364,7 @@ class AttendanceRecordORM(Base): overtime_hours: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="当日打卡推算加班工时") raw_text: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="原始考勤单元格内容") source_type: Mapped[str] = mapped_column(String(32), nullable=False, default="excel", comment="来源:excel或dingtalk") - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间") + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=now_shanghai, comment="创建时间") class LeaveRecordORM(Base): @@ -397,7 +399,7 @@ class LeaveRecordORM(Base): source_type: Mapped[str] = mapped_column(String(32), nullable=False, default="excel", comment="来源:excel或dingtalk") status: Mapped[str] = mapped_column(String(32), nullable=False, default="approved", comment="状态:approved已生效") remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="备注") - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间") + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=now_shanghai, comment="创建时间") class OvertimeRecordORM(Base): @@ -438,7 +440,7 @@ class OvertimeRecordORM(Base): amount: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="加班费金额") source_type: Mapped[str] = mapped_column(String(32), nullable=False, default="excel", comment="来源:excel或dingtalk") status: Mapped[str] = mapped_column(String(32), nullable=False, default="calculated", comment="状态:calculated已计算") - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间") + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=now_shanghai, comment="创建时间") class AttendanceSyncJobORM(Base): @@ -463,12 +465,12 @@ class AttendanceSyncJobORM(Base): record_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="本次同步获得或解析的考勤记录数量") error_message: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="任务失败时的错误信息") created_by: Mapped[str] = mapped_column(String(64), nullable=False, default="", comment="创建人用户名快照") - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间") + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=now_shanghai, comment="创建时间") updated_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, - default=datetime.utcnow, - onupdate=datetime.utcnow, + default=now_shanghai, + onupdate=now_shanghai, comment="更新时间", ) @@ -517,12 +519,12 @@ class SalaryPreviewORM(Base): estimated_net_salary: Mapped[float | None] = mapped_column(Float, nullable=True, comment="当前预估实发工资") status: Mapped[str] = mapped_column(String(32), nullable=False, default="estimated", index=True, comment="预估状态:estimated已预估、warning有异常") note: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="预估备注或异常说明") - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间") + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=now_shanghai, comment="创建时间") updated_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, - default=datetime.utcnow, - onupdate=datetime.utcnow, + default=now_shanghai, + onupdate=now_shanghai, comment="更新时间", ) @@ -553,12 +555,12 @@ class MonthlyPayrollRunORM(Base): locked_by: Mapped[str] = mapped_column(String(64), nullable=False, default="", comment="锁定人用户名快照") locked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="锁定时间") created_by: Mapped[str] = mapped_column(String(64), nullable=False, default="", comment="创建人用户名快照") - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间") + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=now_shanghai, comment="创建时间") updated_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, - default=datetime.utcnow, - onupdate=datetime.utcnow, + default=now_shanghai, + onupdate=now_shanghai, comment="更新时间", ) @@ -599,7 +601,7 @@ class PayrollExceptionORM(Base): severity: Mapped[str] = mapped_column(String(32), nullable=False, default="warning", index=True, comment="严重级别:info、warning、error") message: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="异常说明") status: Mapped[str] = mapped_column(String(32), nullable=False, default="open", index=True, comment="处理状态:open待处理、resolved已处理、ignored已忽略") - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间") + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=now_shanghai, comment="创建时间") resolved_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="处理完成时间") run: Mapped[MonthlyPayrollRunORM | None] = relationship() @@ -630,12 +632,12 @@ class CommissionRecordORM(Base): source_type: Mapped[str] = mapped_column(String(32), nullable=False, default="manual", comment="来源:manual手工、excel导入") business_ref: Mapped[str] = mapped_column(String(128), nullable=False, default="", comment="业务单号或来源标识") remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="备注") - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间") + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=now_shanghai, comment="创建时间") updated_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, - default=datetime.utcnow, - onupdate=datetime.utcnow, + default=now_shanghai, + onupdate=now_shanghai, comment="更新时间", ) @@ -718,7 +720,7 @@ class OperationLogORM(Base): created_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, - default=datetime.utcnow, + default=now_shanghai, index=True, comment="操作时间", ) @@ -760,14 +762,14 @@ class PayrollJobORM(Base): created_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, - default=datetime.utcnow, + default=now_shanghai, comment="创建时间", ) updated_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, - default=datetime.utcnow, - onupdate=datetime.utcnow, + default=now_shanghai, + onupdate=now_shanghai, comment="更新时间", ) @@ -961,12 +963,12 @@ class SalaryRecordORM(Base): net_salary: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="实发工资") status: Mapped[str] = mapped_column(String(32), nullable=False, default="calculated", comment="状态:calculated已计算") remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="备注") - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间") + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=now_shanghai, comment="创建时间") updated_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, - default=datetime.utcnow, - onupdate=datetime.utcnow, + default=now_shanghai, + onupdate=now_shanghai, comment="更新时间", ) @@ -1004,7 +1006,7 @@ class SalaryDetailORM(Base): quantity: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="数量或工时") unit_price: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="单价") remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="备注") - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间") + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=now_shanghai, comment="创建时间") salary_record: Mapped[SalaryRecordORM] = relationship(back_populates="details") @@ -1027,11 +1029,11 @@ class SalaryConfigORM(Base): value_type: Mapped[str] = mapped_column(String(32), nullable=False, default="string", comment="值类型:string、number、integer、boolean、json") is_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, comment="是否启用") remark: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="配置说明") - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, comment="创建时间") + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=now_shanghai, comment="创建时间") updated_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, - default=datetime.utcnow, - onupdate=datetime.utcnow, + default=now_shanghai, + onupdate=now_shanghai, comment="更新时间", ) diff --git a/financial_system/database/session.py b/financial_system/database/session.py index 0893c22..becf7cc 100644 --- a/financial_system/database/session.py +++ b/financial_system/database/session.py @@ -1,6 +1,6 @@ from __future__ import annotations -from sqlalchemy import create_engine, inspect, text +from sqlalchemy import create_engine, event, inspect, text from sqlalchemy.engine import make_url from sqlalchemy.orm import sessionmaker @@ -13,6 +13,21 @@ logger = AppLogger.get_logger(__name__) # SQLite 默认限制跨线程连接;FastAPI 请求线程会复用连接池,需要显式放开。 connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {} engine = create_engine(settings.database_url, connect_args=connect_args) + + +@event.listens_for(engine, "connect") +def _set_mysql_session_timezone(dbapi_connection, connection_record) -> None: + """MySQL 会话统一使用北京时间,避免 CURRENT_TIMESTAMP 少 8 小时。""" + if not settings.database_url.startswith("mysql"): + return + + cursor = dbapi_connection.cursor() + try: + cursor.execute("SET time_zone = '+08:00'") + finally: + cursor.close() + + SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False) USER_PROFILE_COLUMNS = { diff --git a/financial_system/database/sql/init_mysql.sql b/financial_system/database/sql/init_mysql.sql index e4aded5..82fcac5 100644 --- a/financial_system/database/sql/init_mysql.sql +++ b/financial_system/database/sql/init_mysql.sql @@ -7,6 +7,7 @@ CREATE DATABASE IF NOT EXISTS `financial_system` DEFAULT COLLATE utf8mb4_unicode_ci; USE `financial_system`; +SET time_zone = '+08:00'; CREATE TABLE IF NOT EXISTS `users` ( `id` INT NOT NULL AUTO_INCREMENT COMMENT '用户主键ID', diff --git a/financial_system/database/sql/schema.sql b/financial_system/database/sql/schema.sql index 762e101..08963a1 100644 --- a/financial_system/database/sql/schema.sql +++ b/financial_system/database/sql/schema.sql @@ -6,6 +6,7 @@ CREATE DATABASE IF NOT EXISTS `financial_system` DEFAULT COLLATE utf8mb4_unicode_ci; USE `financial_system`; +SET time_zone = '+08:00'; CREATE TABLE IF NOT EXISTS `users` ( `id` INT NOT NULL AUTO_INCREMENT COMMENT '用户主键ID', diff --git a/financial_system/database/sql/upgrade_20260623_timezone_shanghai.sql b/financial_system/database/sql/upgrade_20260623_timezone_shanghai.sql new file mode 100644 index 0000000..248b526 --- /dev/null +++ b/financial_system/database/sql/upgrade_20260623_timezone_shanghai.sql @@ -0,0 +1,115 @@ +-- 修正历史时间:将此前按 UTC 写入的 DATETIME 字段统一调整为北京时间。 +-- 重要:本脚本只能在确认历史数据整体少 8 小时后执行一次,不能重复执行。 +-- 执行示例: +-- mysql -h localhost -P 3306 -u root -p12345678 financial_system < financial_system/database/sql/upgrade_20260623_timezone_shanghai.sql + +SET time_zone = '+08:00'; + +CREATE TABLE IF NOT EXISTS `schema_migrations` ( + `version` VARCHAR(64) NOT NULL COMMENT '迁移版本号', + `description` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '迁移说明', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '执行时间', + PRIMARY KEY (`version`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='数据库迁移执行记录表'; + +SET @migration_version = _utf8mb4'20260623_timezone_shanghai' COLLATE utf8mb4_unicode_ci; +SET @should_run = ( + SELECT IF(COUNT(*) = 0, 1, 0) + FROM `schema_migrations` + WHERE `version` = @migration_version +); + +START TRANSACTION; + +UPDATE `users` +SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR), + `updated_at` = DATE_ADD(`updated_at`, INTERVAL 8 HOUR) +WHERE @should_run = 1; + +UPDATE `departments` +SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR), + `updated_at` = DATE_ADD(`updated_at`, INTERVAL 8 HOUR) +WHERE @should_run = 1; + +UPDATE `positions` +SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR), + `updated_at` = DATE_ADD(`updated_at`, INTERVAL 8 HOUR) +WHERE @should_run = 1; + +UPDATE `employees` +SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR), + `updated_at` = DATE_ADD(`updated_at`, INTERVAL 8 HOUR) +WHERE @should_run = 1; + +UPDATE `salary_profiles` +SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR), + `updated_at` = DATE_ADD(`updated_at`, INTERVAL 8 HOUR) +WHERE @should_run = 1; + +UPDATE `operation_logs` +SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR) +WHERE @should_run = 1; + +UPDATE `payroll_jobs` +SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR), + `updated_at` = DATE_ADD(`updated_at`, INTERVAL 8 HOUR) +WHERE @should_run = 1; + +UPDATE `attendance_record` +SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR) +WHERE @should_run = 1; + +UPDATE `leave_record` +SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR) +WHERE @should_run = 1; + +UPDATE `overtime_record` +SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR) +WHERE @should_run = 1; + +UPDATE `attendance_sync_jobs` +SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR), + `updated_at` = DATE_ADD(`updated_at`, INTERVAL 8 HOUR) +WHERE @should_run = 1; + +UPDATE `salary_preview` +SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR), + `updated_at` = DATE_ADD(`updated_at`, INTERVAL 8 HOUR) +WHERE @should_run = 1; + +UPDATE `monthly_payroll_runs` +SET `locked_at` = CASE WHEN `locked_at` IS NULL THEN NULL ELSE DATE_ADD(`locked_at`, INTERVAL 8 HOUR) END, + `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR), + `updated_at` = DATE_ADD(`updated_at`, INTERVAL 8 HOUR) +WHERE @should_run = 1; + +UPDATE `payroll_exceptions` +SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR), + `resolved_at` = CASE WHEN `resolved_at` IS NULL THEN NULL ELSE DATE_ADD(`resolved_at`, INTERVAL 8 HOUR) END +WHERE @should_run = 1; + +UPDATE `commission_record` +SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR), + `updated_at` = DATE_ADD(`updated_at`, INTERVAL 8 HOUR) +WHERE @should_run = 1; + +UPDATE `salary_record` +SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR), + `updated_at` = DATE_ADD(`updated_at`, INTERVAL 8 HOUR) +WHERE @should_run = 1; + +UPDATE `salary_detail` +SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR) +WHERE @should_run = 1; + +UPDATE `salary_config` +SET `created_at` = DATE_ADD(`created_at`, INTERVAL 8 HOUR), + `updated_at` = DATE_ADD(`updated_at`, INTERVAL 8 HOUR) +WHERE @should_run = 1; + +INSERT INTO `schema_migrations` (`version`, `description`) +SELECT @migration_version, '修正历史 UTC 时间为北京时间' +WHERE @should_run = 1; + +COMMIT; diff --git a/financial_system/io/exporter.py b/financial_system/io/exporter.py index 82a0bee..d08278f 100644 --- a/financial_system/io/exporter.py +++ b/financial_system/io/exporter.py @@ -2,7 +2,7 @@ from __future__ import annotations from pathlib import Path -from openpyxl import Workbook +from openpyxl import Workbook, load_workbook from openpyxl.styles import Alignment, Font, PatternFill from openpyxl.utils import get_column_letter @@ -61,6 +61,13 @@ DAILY_HEADERS = [ "请假/调休记录", ] +SALARY_MODE_LABELS = { + "monthly": "包月", + "hourly": "计时", + "piecework": "计件", + "probation": "试用期", +} + def export_payroll_results(results: list[PayrollResult], config: AppConfig, output_path: str | Path) -> Path: """导出工资汇总、每日明细和规则说明三张表。""" @@ -84,6 +91,26 @@ def export_payroll_results(results: list[PayrollResult], config: AppConfig, outp return output_path +def localize_payroll_export_salary_modes(source_path: str | Path, output_path: str | Path) -> Path: + """复制工资表并把汇总页的薪资模式编码转换为中文标签。""" + source_path = Path(source_path) + output_path = Path(output_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + + workbook = load_workbook(source_path) + if "工资汇总" in workbook.sheetnames: + sheet = workbook["工资汇总"] + salary_mode_column = _find_header_column(sheet, "薪资模式") + if salary_mode_column is not None: + for row_idx in range(2, sheet.max_row + 1): + cell = sheet.cell(row=row_idx, column=salary_mode_column) + if cell.value is not None: + cell.value = _salary_mode_label(str(cell.value)) + + workbook.save(output_path) + return output_path + + def _write_summary(sheet, results: list[PayrollResult]) -> None: sheet.append(SUMMARY_HEADERS) for result in results: @@ -94,7 +121,7 @@ def _write_summary(sheet, results: list[PayrollResult]) -> None: employee.position, employee.attendance_group, result.salary_month, - result.salary_mode, + _salary_mode_label(result.salary_mode), result.attendance_days, result.absence_days, result.actual_work_hours, @@ -192,3 +219,14 @@ def _format_time(value) -> str: if value is None: return "" return value.strftime("%H:%M") + + +def _find_header_column(sheet, header: str) -> int | None: + for cell in sheet[1]: + if cell.value == header: + return cell.column + return None + + +def _salary_mode_label(value: str) -> str: + return SALARY_MODE_LABELS.get(value, value) diff --git a/financial_system/repositories/employee_repository.py b/financial_system/repositories/employee_repository.py index e9d6093..e3079b9 100644 --- a/financial_system/repositories/employee_repository.py +++ b/financial_system/repositories/employee_repository.py @@ -90,9 +90,16 @@ class EmployeeRepository: self, *, keyword: str | None = None, + employee_keyword: str | None = None, + organization_keyword: str | None = None, employment_status: str | None = None, ) -> list[EmployeeORM]: - query = self._filtered_query(keyword=keyword, employment_status=employment_status) + query = self._filtered_query( + keyword=keyword, + employee_keyword=employee_keyword, + organization_keyword=organization_keyword, + employment_status=employment_status, + ) return query.order_by(EmployeeORM.id.desc()).all() def list_employees_page( @@ -101,9 +108,16 @@ class EmployeeRepository: page: int, page_size: int, keyword: str | None = None, + employee_keyword: str | None = None, + organization_keyword: str | None = None, employment_status: str | None = None, ) -> tuple[list[EmployeeORM], int]: - query = self._filtered_query(keyword=keyword, employment_status=employment_status) + query = self._filtered_query( + keyword=keyword, + employee_keyword=employee_keyword, + organization_keyword=organization_keyword, + employment_status=employment_status, + ) total = query.count() items = ( query.order_by(EmployeeORM.id.desc()) @@ -113,7 +127,14 @@ class EmployeeRepository: ) return items, total - def _filtered_query(self, *, keyword: str | None, employment_status: str | None): + def _filtered_query( + self, + *, + keyword: str | None, + employee_keyword: str | None, + organization_keyword: str | None, + employment_status: str | None, + ): query = self.session.query(EmployeeORM) if employment_status: query = query.filter(EmployeeORM.employment_status == employment_status) @@ -129,4 +150,22 @@ class EmployeeRepository: EmployeeORM.phone.like(like_keyword), ) ) + if employee_keyword: + like_employee_keyword = f"%{employee_keyword}%" + query = query.filter( + or_( + EmployeeORM.employee_no.like(like_employee_keyword), + EmployeeORM.name.like(like_employee_keyword), + EmployeeORM.dingtalk_user_id.like(like_employee_keyword), + EmployeeORM.phone.like(like_employee_keyword), + ) + ) + if organization_keyword: + like_organization_keyword = f"%{organization_keyword}%" + query = query.filter( + or_( + EmployeeORM.department.like(like_organization_keyword), + EmployeeORM.position.like(like_organization_keyword), + ) + ) return query diff --git a/financial_system/repositories/monthly_payroll_repository.py b/financial_system/repositories/monthly_payroll_repository.py index 6a1d7c0..18ff14c 100644 --- a/financial_system/repositories/monthly_payroll_repository.py +++ b/financial_system/repositories/monthly_payroll_repository.py @@ -1,9 +1,8 @@ from __future__ import annotations -from datetime import datetime - from sqlalchemy.orm import Session +from ..core.timezone import now_shanghai from ..database.orm import MonthlyPayrollRunORM, PayrollExceptionORM @@ -30,7 +29,7 @@ class MonthlyPayrollRepository: existing.source_job_id = source_job_id existing.status = status existing.created_by = created_by - existing.updated_at = datetime.utcnow() + existing.updated_at = now_shanghai() self.session.commit() self.session.refresh(existing) return existing @@ -90,7 +89,7 @@ class MonthlyPayrollRepository: run.deduction_total = deduction_total run.overtime_total = overtime_total run.status = status - run.updated_at = datetime.utcnow() + run.updated_at = now_shanghai() self.session.commit() self.session.refresh(run) return run @@ -99,8 +98,8 @@ class MonthlyPayrollRepository: run = self.require_run(run_id) run.status = "locked" run.locked_by = locked_by - run.locked_at = datetime.utcnow() - run.updated_at = datetime.utcnow() + run.locked_at = now_shanghai() + run.updated_at = now_shanghai() self.session.commit() self.session.refresh(run) return run diff --git a/financial_system/repositories/realtime_attendance_repository.py b/financial_system/repositories/realtime_attendance_repository.py index 7bed393..7a4b0df 100644 --- a/financial_system/repositories/realtime_attendance_repository.py +++ b/financial_system/repositories/realtime_attendance_repository.py @@ -1,10 +1,11 @@ from __future__ import annotations -from datetime import date, datetime +from datetime import date from uuid import uuid4 from sqlalchemy.orm import Session +from ..core.timezone import now_shanghai from ..database.orm import AttendanceRecordORM, AttendanceSyncJobORM, EmployeeORM, SalaryPreviewORM @@ -70,7 +71,7 @@ class RealtimeAttendanceRepository: job.employee_count = employee_count job.record_count = record_count job.error_message = "" - job.updated_at = datetime.utcnow() + job.updated_at = now_shanghai() self.session.commit() self.session.refresh(job) return job @@ -79,7 +80,7 @@ class RealtimeAttendanceRepository: job = self.require_sync_job(job_id) job.status = "failed" job.error_message = error_message - job.updated_at = datetime.utcnow() + job.updated_at = now_shanghai() self.session.commit() self.session.refresh(job) return job diff --git a/financial_system/services/employee_service.py b/financial_system/services/employee_service.py index 29083c5..dea5cc1 100644 --- a/financial_system/services/employee_service.py +++ b/financial_system/services/employee_service.py @@ -108,6 +108,8 @@ class EmployeeService: self, *, keyword: str | None = None, + employee_keyword: str | None = None, + organization_keyword: str | None = None, employment_status: str | None = None, ) -> list[EmployeeORM]: clean_status = _clean_optional(employment_status) @@ -115,6 +117,8 @@ class EmployeeService: clean_status = _valid_status(clean_status) employees = self.repository.list_employees( keyword=_clean_optional(keyword), + employee_keyword=_clean_optional(employee_keyword), + organization_keyword=_clean_optional(organization_keyword), employment_status=clean_status, ) logger.info("查询员工列表成功 count=%s", len(employees)) @@ -126,6 +130,8 @@ class EmployeeService: page: int, page_size: int, keyword: str | None = None, + employee_keyword: str | None = None, + organization_keyword: str | None = None, employment_status: str | None = None, ) -> EmployeePage: clean_status = _clean_optional(employment_status) @@ -137,6 +143,8 @@ class EmployeeService: page=safe_page, page_size=safe_page_size, keyword=_clean_optional(keyword), + employee_keyword=_clean_optional(employee_keyword), + organization_keyword=_clean_optional(organization_keyword), employment_status=clean_status, ) logger.info("分页查询员工列表成功 total=%s page=%s page_size=%s", total, safe_page, safe_page_size) diff --git a/financial_system/services/monthly_payroll_service.py b/financial_system/services/monthly_payroll_service.py index 7ff8e6d..bf06c2b 100644 --- a/financial_system/services/monthly_payroll_service.py +++ b/financial_system/services/monthly_payroll_service.py @@ -4,6 +4,7 @@ from calendar import monthrange from dataclasses import dataclass from datetime import date from pathlib import Path +import re from typing import BinaryIO from ..core.logger import AppLogger @@ -13,6 +14,7 @@ from ..repositories import EmployeeRepository, MonthlyPayrollRepository from .payroll_service import PayrollApplicationService, PayrollComputation logger = AppLogger.get_logger(__name__) +SALARY_MONTH_RE = re.compile(r"^\d{4}-(0[1-9]|1[0-2])$") @dataclass(frozen=True) @@ -62,8 +64,11 @@ class MonthlyPayrollService: config_path=None, export_excel=export_excel, ) + content_month = self._month_from_computation(computation) + if content_month != salary_month: + raise ValueError(f"Excel 内容月份为 {content_month},与当前核算月份 {salary_month} 不一致") run = self._save_calculation( - salary_month=salary_month or self._month_from_computation(computation), + salary_month=salary_month, source_type="excel", created_by=created_by, computation=computation, @@ -111,8 +116,9 @@ class MonthlyPayrollService: raise FileNotFoundError("该月度核算批次没有可导出的工资表") path = Path(detail.job.output_file) if path.exists(): - return path - return self.payroll_service.resolve_output_file(path.name) + return self.payroll_service.localize_export_file(path, f"monthly_{detail.run.source_type}") + resolved_path = self.payroll_service.resolve_output_file(path.name) + return self.payroll_service.localize_export_file(resolved_path, f"monthly_{detail.run.source_type}") def _save_calculation( self, @@ -169,6 +175,7 @@ class MonthlyPayrollService: return rows def _ensure_month_can_calculate(self, salary_month: str) -> None: + _validate_salary_month(salary_month) existing = self.repository.get_run_by_month(salary_month) if existing and existing.status == "locked": raise ValueError("本月工资已锁定,不能重新计算") @@ -181,7 +188,15 @@ class MonthlyPayrollService: def _month_range(salary_month: str) -> tuple[date, date]: + _validate_salary_month(salary_month) 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]) + + +def _validate_salary_month(salary_month: str) -> None: + if not salary_month: + raise ValueError("请选择核算月份") + if not SALARY_MONTH_RE.match(salary_month): + raise ValueError("核算月份格式错误,请选择正确的月份") diff --git a/financial_system/services/payroll_service.py b/financial_system/services/payroll_service.py index 4d67fa5..39bc8c7 100644 --- a/financial_system/services/payroll_service.py +++ b/financial_system/services/payroll_service.py @@ -11,10 +11,10 @@ from ..core.settings import AppSettings from ..domain.calculator import PayrollCalculator from ..domain.models import EmployeeAttendance, EmployeePayConfig, PayrollResult from ..integrations.dingtalk import DingTalkAttendanceAdapter, DingTalkClient, DingTalkSettings -from ..io.exporter import export_payroll_results +from ..io.exporter import export_payroll_results, localize_payroll_export_salary_modes from ..io.parser import MonthlySummaryParser from ..io.storage import FileStorage -from ..repositories import CommissionRepository, PayrollRepository, SalaryProfileRepository +from ..repositories import CommissionRepository, EmployeeRepository, PayrollRepository, SalaryProfileRepository from .salary_config_service import SalaryConfigService logger = AppLogger.get_logger(__name__) @@ -63,12 +63,14 @@ class PayrollApplicationService: salary_repository: SalaryProfileRepository | None = None, commission_repository: CommissionRepository | None = None, config_service: SalaryConfigService | None = None, + employee_repository: EmployeeRepository | None = None, ): self.repository = repository self.settings = settings self.salary_repository = salary_repository self.commission_repository = commission_repository self.config_service = config_service + self.employee_repository = employee_repository self.storage = FileStorage(settings) def calculate_from_excel_upload( @@ -85,6 +87,7 @@ class PayrollApplicationService: try: config = self._load_runtime_config(config_path) employees = MonthlySummaryParser(config.attendance).parse(upload_path) + self._enrich_employees_from_maintenance(employees) salary_month = self._salary_month_from_employees(employees) config = self._load_runtime_config(config_path, salary_month=salary_month) results = PayrollCalculator(config).calculate(employees) @@ -129,6 +132,7 @@ class PayrollApplicationService: records = await client.list_attendance_records(user_ids, start_dt, end_dt) adapter = DingTalkAttendanceAdapter(config.attendance, timezone=settings.timezone) employees = adapter.to_employee_attendance(records) + self._enrich_employees_from_maintenance(employees) results = PayrollCalculator(config).calculate(employees) output_file = self._export_if_needed(config, results, "dingtalk", export_excel) self.repository.save_results(job.id, results) @@ -155,6 +159,13 @@ class PayrollApplicationService: logger.info("解析工资结果下载文件 filename=%s", filename) return self.storage.resolve_output_file(filename) + def localize_export_file(self, source_path: str | Path, source_type: str) -> Path: + """生成一份用户下载用工资表副本,修正历史文件中的薪资模式编码。""" + output_path = self.storage.payroll_output_path(source_type) + localize_payroll_export_salary_modes(source_path, output_path) + logger.info("工资表导出副本已中文化 source_path=%s output_path=%s", source_path, output_path) + return output_path + 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) @@ -228,6 +239,48 @@ class PayrollApplicationService: ), ) + def _enrich_employees_from_maintenance(self, employees: list[EmployeeAttendance]) -> None: + """优先使用员工维护中的员工编号、部门和岗位,未维护时保留 Excel/钉钉原值。""" + if self.employee_repository is None or not employees: + return + + maintained_employees = self.employee_repository.list_employees() + by_employee_no = { + _clean_key(employee.employee_no): employee + for employee in maintained_employees + if _clean_key(employee.employee_no) + } + by_dingtalk_user_id = { + _clean_key(employee.dingtalk_user_id): employee + for employee in maintained_employees + if _clean_key(employee.dingtalk_user_id) + } + by_name = { + _clean_key(employee.name): employee + for employee in maintained_employees + if _clean_key(employee.name) + } + + enriched_count = 0 + for employee in employees: + maintained = ( + by_employee_no.get(_clean_key(employee.employee_no)) + or by_dingtalk_user_id.get(_clean_key(employee.user_id)) + or by_name.get(_clean_key(employee.name)) + ) + if maintained is None: + continue + + employee.employee_no = maintained.employee_no or employee.employee_no + employee.name = maintained.name or employee.name + employee.user_id = maintained.dingtalk_user_id or employee.user_id + employee.department = maintained.department or employee.department + employee.position = maintained.position or employee.position + enriched_count += 1 + + if enriched_count: + logger.info("已使用员工维护数据补齐工资计算员工信息 count=%s", enriched_count) + def _salary_month_from_employees(self, employees: list[EmployeeAttendance]) -> str | None: for employee in employees: if employee.daily_records: @@ -244,3 +297,7 @@ class PayrollApplicationService: base_url=self.settings.dingtalk_base_url, timezone=self.settings.dingtalk_timezone, ) + + +def _clean_key(value: object) -> str: + return str(value or "").strip()