修复后端时区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.
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

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 PYTHONUNBUFFERED=1

View File

@ -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 小时后再执行 |
主要业务表:

View File

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

View File

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

View File

@ -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 = {

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.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="更新时间",
)

View File

@ -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 = {

View File

@ -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',

View File

@ -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',

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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("核算月份格式错误,请选择正确的月份")

View File

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