修复后端时区BUG

This commit is contained in:
焦龙言 2026-06-29 14:49:32 +08:00
parent af1db3de4f
commit d6f858b363
19 changed files with 391 additions and 71 deletions

4
.env
View File

@ -5,5 +5,5 @@
# If MySQL runs on another server, use that server's reachable IP address or domain name. # 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 DATABASE_URL=mysql+pymysql://root:Plo6lvzOPtMNzVIA@host.docker.internal:3306/financial_system?charset=utf8mb4
BACKEND_PORT=8000 BACKEND_PORT=8005
FRONTEND_PORT=5173 FRONTEND_PORT=5179

View File

@ -1,4 +1,4 @@
FROM python:3.12-slim FROM docker.m.daocloud.io/library/python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1

View File

@ -137,6 +137,7 @@ config/app_settings.json
| --- | --- | | --- | --- |
| `server.host` / `server.port` | 后端监听地址和端口 | | `server.host` / `server.port` | 后端监听地址和端口 |
| `database.url` | 数据库连接 | | `database.url` | 数据库连接 |
| 系统时区 | 后端统一使用 `Asia/Shanghai`MySQL 会话启动时自动设置为 `+08:00` |
| `dingtalk.app_key` / `dingtalk.app_secret` | 钉钉开放平台凭证 | | `dingtalk.app_key` / `dingtalk.app_secret` | 钉钉开放平台凭证 |
| `storage.upload_dir` | 上传文件目录 | | `storage.upload_dir` | 上传文件目录 |
| `storage.output_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 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 文件说明: SQL 文件说明:
| 文件 | 说明 | | 文件 | 说明 |
@ -196,6 +203,7 @@ SQL 文件说明:
| `init_mysql.sql` | 一键初始化脚本,包含建库、建表、默认配置、默认管理员 | | `init_mysql.sql` | 一键初始化脚本,包含建库、建表、默认配置、默认管理员 |
| `upgrade_20260617_payroll_modules.sql` | 当前薪酬考勤完整模块升级脚本 | | `upgrade_20260617_payroll_modules.sql` | 当前薪酬考勤完整模块升级脚本 |
| `upgrade_20260618_monthly_payroll_center.sql` | 实时考勤同步、薪资预估、月度核算批次和异常清单升级脚本 | | `upgrade_20260618_monthly_payroll_center.sql` | 实时考勤同步、薪资预估、月度核算批次和异常清单升级脚本 |
| `upgrade_20260623_timezone_shanghai.sql` | 一次性修正历史 UTC 时间为北京时间,确认整体少 8 小时后再执行 |
主要业务表: 主要业务表:

View File

@ -162,6 +162,7 @@ def get_payroll_service(
repository: PayrollRepository = Depends(get_payroll_repository), repository: PayrollRepository = Depends(get_payroll_repository),
salary_repository: SalaryProfileRepository = Depends(get_salary_profile_repository), salary_repository: SalaryProfileRepository = Depends(get_salary_profile_repository),
commission_repository: CommissionRepository = Depends(get_commission_repository), commission_repository: CommissionRepository = Depends(get_commission_repository),
employee_repository: EmployeeRepository = Depends(get_employee_repository),
config_repository: SalaryConfigRepository = Depends(get_salary_config_repository), config_repository: SalaryConfigRepository = Depends(get_salary_config_repository),
settings: AppSettings = Depends(get_app_settings), settings: AppSettings = Depends(get_app_settings),
) -> PayrollApplicationService: ) -> PayrollApplicationService:
@ -171,6 +172,7 @@ def get_payroll_service(
salary_repository=salary_repository, salary_repository=salary_repository,
commission_repository=commission_repository, commission_repository=commission_repository,
config_service=SalaryConfigService(config_repository), config_service=SalaryConfigService(config_repository),
employee_repository=employee_repository,
) )

View File

@ -40,6 +40,8 @@ def preview_next_employee_no(
def list_employees( def list_employees(
http_request: Request, http_request: Request,
keyword: str | None = Query(default=None, description="员工编号、姓名、部门、岗位等关键字"), 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"), employment_status: str | None = Query(default=None, description="员工状态active或inactive"),
current_user: UserORM = Depends(require_permission(PERMISSION_EMPLOYEE_VIEW)), current_user: UserORM = Depends(require_permission(PERMISSION_EMPLOYEE_VIEW)),
service: EmployeeService = Depends(get_employee_service), service: EmployeeService = Depends(get_employee_service),
@ -47,7 +49,12 @@ def list_employees(
) -> list[EmployeeResponse]: ) -> list[EmployeeResponse]:
"""查询员工档案。""" """查询员工档案。"""
try: 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: except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
operation_logs.record( operation_logs.record(
@ -69,6 +76,8 @@ def list_employees_page(
page: int = Query(default=1, ge=1, description="页码从1开始"), page: int = Query(default=1, ge=1, description="页码从1开始"),
page_size: int = Query(default=10, ge=1, le=100, description="每页条数最大100"), page_size: int = Query(default=10, ge=1, le=100, description="每页条数最大100"),
keyword: str | None = Query(default=None, description="员工编号、姓名、部门、岗位等关键字"), 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"), employment_status: str | None = Query(default=None, description="员工状态active或inactive"),
current_user: UserORM = Depends(require_permission(PERMISSION_EMPLOYEE_VIEW)), current_user: UserORM = Depends(require_permission(PERMISSION_EMPLOYEE_VIEW)),
service: EmployeeService = Depends(get_employee_service), service: EmployeeService = Depends(get_employee_service),
@ -80,6 +89,8 @@ def list_employees_page(
page=page, page=page,
page_size=page_size, page_size=page_size,
keyword=keyword, keyword=keyword,
employee_keyword=employee_keyword,
organization_keyword=organization_keyword,
employment_status=employment_status, employment_status=employment_status,
) )
except ValueError as exc: except ValueError as exc:

View File

@ -62,8 +62,6 @@ ROLE_MENU_CODES = {
ROLE_SUPERUSER: ( ROLE_SUPERUSER: (
"attendance_realtime", "attendance_realtime",
"monthly_payroll", "monthly_payroll",
"payroll",
"jobs",
"employees", "employees",
"organization", "organization",
"salary_profiles", "salary_profiles",
@ -76,8 +74,6 @@ ROLE_MENU_CODES = {
ROLE_MANAGER: ( ROLE_MANAGER: (
"attendance_realtime", "attendance_realtime",
"monthly_payroll", "monthly_payroll",
"payroll",
"jobs",
"employees", "employees",
"organization", "organization",
"salary_profiles", "salary_profiles",
@ -86,7 +82,7 @@ ROLE_MENU_CODES = {
"configs", "configs",
"operation_logs", "operation_logs",
), ),
ROLE_VIEWER: ("attendance_realtime", "jobs", "reports"), ROLE_VIEWER: ("attendance_realtime", "monthly_payroll", "reports"),
} }
ROLE_OPERATION_PERMISSIONS = { ROLE_OPERATION_PERMISSIONS = {

View File

@ -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)

View File

@ -5,6 +5,8 @@ from datetime import date, datetime
from sqlalchemy import Boolean, Date, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint from sqlalchemy import Boolean, Date, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from ..core.timezone import now_shanghai
class Base(DeclarativeBase): class Base(DeclarativeBase):
pass pass
@ -84,14 +86,14 @@ class UserORM(Base):
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
comment="创建时间", comment="创建时间",
) )
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
onupdate=datetime.utcnow, onupdate=now_shanghai,
comment="更新时间", comment="更新时间",
) )
@ -124,12 +126,12 @@ class DepartmentORM(Base):
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="排序号,越小越靠前") sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="排序号,越小越靠前")
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True, comment="是否启用") is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True, comment="是否启用")
remark: Mapped[str] = mapped_column(Text, 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( updated_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
onupdate=datetime.utcnow, onupdate=now_shanghai,
comment="更新时间", comment="更新时间",
) )
@ -165,12 +167,12 @@ class PositionORM(Base):
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="排序号,越小越靠前") sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="排序号,越小越靠前")
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True, comment="是否启用") is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True, comment="是否启用")
remark: Mapped[str] = mapped_column(Text, 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( updated_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
onupdate=datetime.utcnow, onupdate=now_shanghai,
comment="更新时间", comment="更新时间",
) )
@ -217,14 +219,14 @@ class EmployeeORM(Base):
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
comment="创建时间", comment="创建时间",
) )
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
onupdate=datetime.utcnow, onupdate=now_shanghai,
comment="更新时间", comment="更新时间",
) )
@ -306,14 +308,14 @@ class SalaryProfileORM(Base):
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
comment="创建时间", comment="创建时间",
) )
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
onupdate=datetime.utcnow, onupdate=now_shanghai,
comment="更新时间", comment="更新时间",
) )
@ -362,7 +364,7 @@ class AttendanceRecordORM(Base):
overtime_hours: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="当日打卡推算加班工时") overtime_hours: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="当日打卡推算加班工时")
raw_text: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="原始考勤单元格内容") 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") 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): 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") 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已生效") status: Mapped[str] = mapped_column(String(32), nullable=False, default="approved", comment="状态approved已生效")
remark: Mapped[str] = mapped_column(Text, 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="创建时间")
class OvertimeRecordORM(Base): class OvertimeRecordORM(Base):
@ -438,7 +440,7 @@ class OvertimeRecordORM(Base):
amount: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="加班费金额") amount: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="加班费金额")
source_type: Mapped[str] = mapped_column(String(32), nullable=False, default="excel", comment="来源excel或dingtalk") 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已计算") 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): class AttendanceSyncJobORM(Base):
@ -463,12 +465,12 @@ class AttendanceSyncJobORM(Base):
record_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="本次同步获得或解析的考勤记录数量") record_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="本次同步获得或解析的考勤记录数量")
error_message: Mapped[str] = mapped_column(Text, nullable=False, default="", 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_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( updated_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
onupdate=datetime.utcnow, onupdate=now_shanghai,
comment="更新时间", comment="更新时间",
) )
@ -517,12 +519,12 @@ class SalaryPreviewORM(Base):
estimated_net_salary: Mapped[float | None] = mapped_column(Float, nullable=True, comment="当前预估实发工资") 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有异常") 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="预估备注或异常说明") 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( updated_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
onupdate=datetime.utcnow, onupdate=now_shanghai,
comment="更新时间", comment="更新时间",
) )
@ -553,12 +555,12 @@ class MonthlyPayrollRunORM(Base):
locked_by: Mapped[str] = mapped_column(String(64), nullable=False, default="", comment="锁定人用户名快照") locked_by: Mapped[str] = mapped_column(String(64), nullable=False, default="", comment="锁定人用户名快照")
locked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, 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_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( updated_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
onupdate=datetime.utcnow, onupdate=now_shanghai,
comment="更新时间", 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") 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="异常说明") 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已忽略") 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="处理完成时间") resolved_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="处理完成时间")
run: Mapped[MonthlyPayrollRunORM | None] = relationship() 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导入") 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="业务单号或来源标识") business_ref: Mapped[str] = mapped_column(String(128), nullable=False, default="", comment="业务单号或来源标识")
remark: Mapped[str] = mapped_column(Text, 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( updated_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
onupdate=datetime.utcnow, onupdate=now_shanghai,
comment="更新时间", comment="更新时间",
) )
@ -718,7 +720,7 @@ class OperationLogORM(Base):
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
index=True, index=True,
comment="操作时间", comment="操作时间",
) )
@ -760,14 +762,14 @@ class PayrollJobORM(Base):
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
comment="创建时间", comment="创建时间",
) )
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
onupdate=datetime.utcnow, onupdate=now_shanghai,
comment="更新时间", comment="更新时间",
) )
@ -961,12 +963,12 @@ class SalaryRecordORM(Base):
net_salary: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="实发工资") net_salary: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="实发工资")
status: Mapped[str] = mapped_column(String(32), nullable=False, default="calculated", comment="状态calculated已计算") status: Mapped[str] = mapped_column(String(32), nullable=False, default="calculated", comment="状态calculated已计算")
remark: Mapped[str] = mapped_column(Text, 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( updated_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
onupdate=datetime.utcnow, onupdate=now_shanghai,
comment="更新时间", comment="更新时间",
) )
@ -1004,7 +1006,7 @@ class SalaryDetailORM(Base):
quantity: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="数量或工时") quantity: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="数量或工时")
unit_price: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="单价") unit_price: Mapped[float] = mapped_column(Float, nullable=False, default=0, comment="单价")
remark: Mapped[str] = mapped_column(Text, 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="创建时间")
salary_record: Mapped[SalaryRecordORM] = relationship(back_populates="details") 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") 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="是否启用") is_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, comment="是否启用")
remark: Mapped[str] = mapped_column(Text, 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( updated_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
default=datetime.utcnow, default=now_shanghai,
onupdate=datetime.utcnow, onupdate=now_shanghai,
comment="更新时间", comment="更新时间",
) )

View File

@ -1,6 +1,6 @@
from __future__ import annotations 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.engine import make_url
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
@ -13,6 +13,21 @@ logger = AppLogger.get_logger(__name__)
# SQLite 默认限制跨线程连接FastAPI 请求线程会复用连接池,需要显式放开。 # SQLite 默认限制跨线程连接FastAPI 请求线程会复用连接池,需要显式放开。
connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {} connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
engine = create_engine(settings.database_url, connect_args=connect_args) 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) SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
USER_PROFILE_COLUMNS = { USER_PROFILE_COLUMNS = {

View File

@ -7,6 +7,7 @@ CREATE DATABASE IF NOT EXISTS `financial_system`
DEFAULT COLLATE utf8mb4_unicode_ci; DEFAULT COLLATE utf8mb4_unicode_ci;
USE `financial_system`; USE `financial_system`;
SET time_zone = '+08:00';
CREATE TABLE IF NOT EXISTS `users` ( CREATE TABLE IF NOT EXISTS `users` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '用户主键ID', `id` INT NOT NULL AUTO_INCREMENT COMMENT '用户主键ID',

View File

@ -6,6 +6,7 @@ CREATE DATABASE IF NOT EXISTS `financial_system`
DEFAULT COLLATE utf8mb4_unicode_ci; DEFAULT COLLATE utf8mb4_unicode_ci;
USE `financial_system`; USE `financial_system`;
SET time_zone = '+08:00';
CREATE TABLE IF NOT EXISTS `users` ( CREATE TABLE IF NOT EXISTS `users` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '用户主键ID', `id` INT NOT NULL AUTO_INCREMENT COMMENT '用户主键ID',

View File

@ -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;

View File

@ -2,7 +2,7 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from openpyxl import Workbook from openpyxl import Workbook, load_workbook
from openpyxl.styles import Alignment, Font, PatternFill from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.utils import get_column_letter 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: 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 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: def _write_summary(sheet, results: list[PayrollResult]) -> None:
sheet.append(SUMMARY_HEADERS) sheet.append(SUMMARY_HEADERS)
for result in results: for result in results:
@ -94,7 +121,7 @@ def _write_summary(sheet, results: list[PayrollResult]) -> None:
employee.position, employee.position,
employee.attendance_group, employee.attendance_group,
result.salary_month, result.salary_month,
result.salary_mode, _salary_mode_label(result.salary_mode),
result.attendance_days, result.attendance_days,
result.absence_days, result.absence_days,
result.actual_work_hours, result.actual_work_hours,
@ -192,3 +219,14 @@ def _format_time(value) -> str:
if value is None: if value is None:
return "" return ""
return value.strftime("%H:%M") 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)

View File

@ -90,9 +90,16 @@ class EmployeeRepository:
self, self,
*, *,
keyword: str | None = None, keyword: str | None = None,
employee_keyword: str | None = None,
organization_keyword: str | None = None,
employment_status: str | None = None, employment_status: str | None = None,
) -> list[EmployeeORM]: ) -> 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() return query.order_by(EmployeeORM.id.desc()).all()
def list_employees_page( def list_employees_page(
@ -101,9 +108,16 @@ class EmployeeRepository:
page: int, page: int,
page_size: int, page_size: int,
keyword: str | None = None, keyword: str | None = None,
employee_keyword: str | None = None,
organization_keyword: str | None = None,
employment_status: str | None = None, employment_status: str | None = None,
) -> tuple[list[EmployeeORM], int]: ) -> 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() total = query.count()
items = ( items = (
query.order_by(EmployeeORM.id.desc()) query.order_by(EmployeeORM.id.desc())
@ -113,7 +127,14 @@ class EmployeeRepository:
) )
return items, total 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) query = self.session.query(EmployeeORM)
if employment_status: if employment_status:
query = query.filter(EmployeeORM.employment_status == employment_status) query = query.filter(EmployeeORM.employment_status == employment_status)
@ -129,4 +150,22 @@ class EmployeeRepository:
EmployeeORM.phone.like(like_keyword), 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 return query

View File

@ -1,9 +1,8 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..core.timezone import now_shanghai
from ..database.orm import MonthlyPayrollRunORM, PayrollExceptionORM from ..database.orm import MonthlyPayrollRunORM, PayrollExceptionORM
@ -30,7 +29,7 @@ class MonthlyPayrollRepository:
existing.source_job_id = source_job_id existing.source_job_id = source_job_id
existing.status = status existing.status = status
existing.created_by = created_by existing.created_by = created_by
existing.updated_at = datetime.utcnow() existing.updated_at = now_shanghai()
self.session.commit() self.session.commit()
self.session.refresh(existing) self.session.refresh(existing)
return existing return existing
@ -90,7 +89,7 @@ class MonthlyPayrollRepository:
run.deduction_total = deduction_total run.deduction_total = deduction_total
run.overtime_total = overtime_total run.overtime_total = overtime_total
run.status = status run.status = status
run.updated_at = datetime.utcnow() run.updated_at = now_shanghai()
self.session.commit() self.session.commit()
self.session.refresh(run) self.session.refresh(run)
return run return run
@ -99,8 +98,8 @@ class MonthlyPayrollRepository:
run = self.require_run(run_id) run = self.require_run(run_id)
run.status = "locked" run.status = "locked"
run.locked_by = locked_by run.locked_by = locked_by
run.locked_at = datetime.utcnow() run.locked_at = now_shanghai()
run.updated_at = datetime.utcnow() run.updated_at = now_shanghai()
self.session.commit() self.session.commit()
self.session.refresh(run) self.session.refresh(run)
return run return run

View File

@ -1,10 +1,11 @@
from __future__ import annotations from __future__ import annotations
from datetime import date, datetime from datetime import date
from uuid import uuid4 from uuid import uuid4
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..core.timezone import now_shanghai
from ..database.orm import AttendanceRecordORM, AttendanceSyncJobORM, EmployeeORM, SalaryPreviewORM from ..database.orm import AttendanceRecordORM, AttendanceSyncJobORM, EmployeeORM, SalaryPreviewORM
@ -70,7 +71,7 @@ class RealtimeAttendanceRepository:
job.employee_count = employee_count job.employee_count = employee_count
job.record_count = record_count job.record_count = record_count
job.error_message = "" job.error_message = ""
job.updated_at = datetime.utcnow() job.updated_at = now_shanghai()
self.session.commit() self.session.commit()
self.session.refresh(job) self.session.refresh(job)
return job return job
@ -79,7 +80,7 @@ class RealtimeAttendanceRepository:
job = self.require_sync_job(job_id) job = self.require_sync_job(job_id)
job.status = "failed" job.status = "failed"
job.error_message = error_message job.error_message = error_message
job.updated_at = datetime.utcnow() job.updated_at = now_shanghai()
self.session.commit() self.session.commit()
self.session.refresh(job) self.session.refresh(job)
return job return job

View File

@ -108,6 +108,8 @@ class EmployeeService:
self, self,
*, *,
keyword: str | None = None, keyword: str | None = None,
employee_keyword: str | None = None,
organization_keyword: str | None = None,
employment_status: str | None = None, employment_status: str | None = None,
) -> list[EmployeeORM]: ) -> list[EmployeeORM]:
clean_status = _clean_optional(employment_status) clean_status = _clean_optional(employment_status)
@ -115,6 +117,8 @@ class EmployeeService:
clean_status = _valid_status(clean_status) clean_status = _valid_status(clean_status)
employees = self.repository.list_employees( employees = self.repository.list_employees(
keyword=_clean_optional(keyword), keyword=_clean_optional(keyword),
employee_keyword=_clean_optional(employee_keyword),
organization_keyword=_clean_optional(organization_keyword),
employment_status=clean_status, employment_status=clean_status,
) )
logger.info("查询员工列表成功 count=%s", len(employees)) logger.info("查询员工列表成功 count=%s", len(employees))
@ -126,6 +130,8 @@ class EmployeeService:
page: int, page: int,
page_size: int, page_size: int,
keyword: str | None = None, keyword: str | None = None,
employee_keyword: str | None = None,
organization_keyword: str | None = None,
employment_status: str | None = None, employment_status: str | None = None,
) -> EmployeePage: ) -> EmployeePage:
clean_status = _clean_optional(employment_status) clean_status = _clean_optional(employment_status)
@ -137,6 +143,8 @@ class EmployeeService:
page=safe_page, page=safe_page,
page_size=safe_page_size, page_size=safe_page_size,
keyword=_clean_optional(keyword), keyword=_clean_optional(keyword),
employee_keyword=_clean_optional(employee_keyword),
organization_keyword=_clean_optional(organization_keyword),
employment_status=clean_status, employment_status=clean_status,
) )
logger.info("分页查询员工列表成功 total=%s page=%s page_size=%s", total, safe_page, safe_page_size) logger.info("分页查询员工列表成功 total=%s page=%s page_size=%s", total, safe_page, safe_page_size)

View File

@ -4,6 +4,7 @@ from calendar import monthrange
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date from datetime import date
from pathlib import Path from pathlib import Path
import re
from typing import BinaryIO from typing import BinaryIO
from ..core.logger import AppLogger from ..core.logger import AppLogger
@ -13,6 +14,7 @@ from ..repositories import EmployeeRepository, MonthlyPayrollRepository
from .payroll_service import PayrollApplicationService, PayrollComputation from .payroll_service import PayrollApplicationService, PayrollComputation
logger = AppLogger.get_logger(__name__) logger = AppLogger.get_logger(__name__)
SALARY_MONTH_RE = re.compile(r"^\d{4}-(0[1-9]|1[0-2])$")
@dataclass(frozen=True) @dataclass(frozen=True)
@ -62,8 +64,11 @@ class MonthlyPayrollService:
config_path=None, config_path=None,
export_excel=export_excel, 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( run = self._save_calculation(
salary_month=salary_month or self._month_from_computation(computation), salary_month=salary_month,
source_type="excel", source_type="excel",
created_by=created_by, created_by=created_by,
computation=computation, computation=computation,
@ -111,8 +116,9 @@ class MonthlyPayrollService:
raise FileNotFoundError("该月度核算批次没有可导出的工资表") raise FileNotFoundError("该月度核算批次没有可导出的工资表")
path = Path(detail.job.output_file) path = Path(detail.job.output_file)
if path.exists(): if path.exists():
return path return self.payroll_service.localize_export_file(path, f"monthly_{detail.run.source_type}")
return self.payroll_service.resolve_output_file(path.name) 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( def _save_calculation(
self, self,
@ -169,6 +175,7 @@ class MonthlyPayrollService:
return rows return rows
def _ensure_month_can_calculate(self, salary_month: str) -> None: def _ensure_month_can_calculate(self, salary_month: str) -> None:
_validate_salary_month(salary_month)
existing = self.repository.get_run_by_month(salary_month) existing = self.repository.get_run_by_month(salary_month)
if existing and existing.status == "locked": if existing and existing.status == "locked":
raise ValueError("本月工资已锁定,不能重新计算") raise ValueError("本月工资已锁定,不能重新计算")
@ -181,7 +188,15 @@ class MonthlyPayrollService:
def _month_range(salary_month: str) -> tuple[date, date]: def _month_range(salary_month: str) -> tuple[date, date]:
_validate_salary_month(salary_month)
year_text, month_text = salary_month.split("-", 1) year_text, month_text = salary_month.split("-", 1)
year = int(year_text) year = int(year_text)
month = int(month_text) month = int(month_text)
return date(year, month, 1), date(year, month, monthrange(year, month)[1]) 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("核算月份格式错误,请选择正确的月份")

View File

@ -11,10 +11,10 @@ from ..core.settings import AppSettings
from ..domain.calculator import PayrollCalculator from ..domain.calculator import PayrollCalculator
from ..domain.models import EmployeeAttendance, EmployeePayConfig, PayrollResult from ..domain.models import EmployeeAttendance, EmployeePayConfig, PayrollResult
from ..integrations.dingtalk import DingTalkAttendanceAdapter, DingTalkClient, DingTalkSettings 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.parser import MonthlySummaryParser
from ..io.storage import FileStorage from ..io.storage import FileStorage
from ..repositories import CommissionRepository, PayrollRepository, SalaryProfileRepository from ..repositories import CommissionRepository, EmployeeRepository, PayrollRepository, SalaryProfileRepository
from .salary_config_service import SalaryConfigService from .salary_config_service import SalaryConfigService
logger = AppLogger.get_logger(__name__) logger = AppLogger.get_logger(__name__)
@ -63,12 +63,14 @@ class PayrollApplicationService:
salary_repository: SalaryProfileRepository | None = None, salary_repository: SalaryProfileRepository | None = None,
commission_repository: CommissionRepository | None = None, commission_repository: CommissionRepository | None = None,
config_service: SalaryConfigService | None = None, config_service: SalaryConfigService | None = None,
employee_repository: EmployeeRepository | None = None,
): ):
self.repository = repository self.repository = repository
self.settings = settings self.settings = settings
self.salary_repository = salary_repository self.salary_repository = salary_repository
self.commission_repository = commission_repository self.commission_repository = commission_repository
self.config_service = config_service self.config_service = config_service
self.employee_repository = employee_repository
self.storage = FileStorage(settings) self.storage = FileStorage(settings)
def calculate_from_excel_upload( def calculate_from_excel_upload(
@ -85,6 +87,7 @@ class PayrollApplicationService:
try: try:
config = self._load_runtime_config(config_path) config = self._load_runtime_config(config_path)
employees = MonthlySummaryParser(config.attendance).parse(upload_path) employees = MonthlySummaryParser(config.attendance).parse(upload_path)
self._enrich_employees_from_maintenance(employees)
salary_month = self._salary_month_from_employees(employees) salary_month = self._salary_month_from_employees(employees)
config = self._load_runtime_config(config_path, salary_month=salary_month) config = self._load_runtime_config(config_path, salary_month=salary_month)
results = PayrollCalculator(config).calculate(employees) results = PayrollCalculator(config).calculate(employees)
@ -129,6 +132,7 @@ class PayrollApplicationService:
records = await client.list_attendance_records(user_ids, start_dt, end_dt) records = await client.list_attendance_records(user_ids, start_dt, end_dt)
adapter = DingTalkAttendanceAdapter(config.attendance, timezone=settings.timezone) adapter = DingTalkAttendanceAdapter(config.attendance, timezone=settings.timezone)
employees = adapter.to_employee_attendance(records) employees = adapter.to_employee_attendance(records)
self._enrich_employees_from_maintenance(employees)
results = PayrollCalculator(config).calculate(employees) results = PayrollCalculator(config).calculate(employees)
output_file = self._export_if_needed(config, results, "dingtalk", export_excel) output_file = self._export_if_needed(config, results, "dingtalk", export_excel)
self.repository.save_results(job.id, results) self.repository.save_results(job.id, results)
@ -155,6 +159,13 @@ class PayrollApplicationService:
logger.info("解析工资结果下载文件 filename=%s", filename) logger.info("解析工资结果下载文件 filename=%s", filename)
return self.storage.resolve_output_file(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: 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) 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: def _salary_month_from_employees(self, employees: list[EmployeeAttendance]) -> str | None:
for employee in employees: for employee in employees:
if employee.daily_records: if employee.daily_records:
@ -244,3 +297,7 @@ class PayrollApplicationService:
base_url=self.settings.dingtalk_base_url, base_url=self.settings.dingtalk_base_url,
timezone=self.settings.dingtalk_timezone, timezone=self.settings.dingtalk_timezone,
) )
def _clean_key(value: object) -> str:
return str(value or "").strip()