修复后端时区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.
|
# 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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 小时后再执行 |
|
||||||
|
|
||||||
主要业务表:
|
主要业务表:
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
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 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="更新时间",
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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 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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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("核算月份格式错误,请选择正确的月份")
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user