修复后端时区BUG
This commit is contained in:
parent
af1db3de4f
commit
d6f858b363
4
.env
4
.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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 小时后再执行 |
|
||||
|
||||
主要业务表:
|
||||
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 = {
|
||||
|
||||
12
financial_system/core/timezone.py
Normal file
12
financial_system/core/timezone.py
Normal 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)
|
||||
|
||||
@ -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="更新时间",
|
||||
)
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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;
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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("核算月份格式错误,请选择正确的月份")
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user