233 lines
7.3 KiB
Python
233 lines
7.3 KiB
Python
from __future__ import annotations
|
||
|
||
from pathlib import Path
|
||
|
||
from openpyxl import Workbook, load_workbook
|
||
from openpyxl.styles import Alignment, Font, PatternFill
|
||
from openpyxl.utils import get_column_letter
|
||
|
||
from ..core.config import AppConfig
|
||
from ..domain.models import PayrollResult
|
||
from .parser import iter_leave_event_descriptions
|
||
|
||
|
||
SUMMARY_HEADERS = [
|
||
"姓名",
|
||
"部门",
|
||
"职位",
|
||
"考勤组",
|
||
"工资月份",
|
||
"薪资模式",
|
||
"出勤天数",
|
||
"缺勤天数",
|
||
"实际工作工时",
|
||
"休息天数",
|
||
"系统加班总时长",
|
||
"打卡推算加班工时",
|
||
"请假/调休工时",
|
||
"剩余加班工时",
|
||
"工作日加班工时",
|
||
"周末加班工时",
|
||
"节假日加班工时",
|
||
"5分钟内迟到次数",
|
||
"超过5分钟迟到次数",
|
||
"计扣迟到次数",
|
||
"迟到扣款",
|
||
"缺卡次数",
|
||
"缺卡扣款",
|
||
"扣款合计",
|
||
"底薪",
|
||
"模式基础工资",
|
||
"提成",
|
||
"工作日加班单价",
|
||
"周末加班单价",
|
||
"节假日加班单价",
|
||
"加班费",
|
||
"应发工资",
|
||
"实发工资",
|
||
"备注",
|
||
]
|
||
|
||
DAILY_HEADERS = [
|
||
"姓名",
|
||
"日期",
|
||
"原始考勤结果",
|
||
"上班打卡",
|
||
"下班打卡",
|
||
"打卡推算加班工时",
|
||
"迟到分钟",
|
||
"缺卡次数",
|
||
"请假/调休工时(本格)",
|
||
"请假/调休记录",
|
||
]
|
||
|
||
SALARY_MODE_LABELS = {
|
||
"monthly": "包月",
|
||
"hourly": "计时",
|
||
"piecework": "计件",
|
||
"probation": "试用期",
|
||
}
|
||
|
||
|
||
def export_payroll_results(results: list[PayrollResult], config: AppConfig, output_path: str | Path) -> Path:
|
||
"""导出工资汇总、每日明细和规则说明三张表。"""
|
||
output_path = Path(output_path)
|
||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||
|
||
workbook = Workbook()
|
||
summary_sheet = workbook.active
|
||
summary_sheet.title = "工资汇总"
|
||
daily_sheet = workbook.create_sheet("每日明细")
|
||
rules_sheet = workbook.create_sheet("规则说明")
|
||
|
||
_write_summary(summary_sheet, results)
|
||
_write_daily_details(daily_sheet, results)
|
||
_write_rules(rules_sheet, config)
|
||
|
||
for sheet in workbook.worksheets:
|
||
_style_sheet(sheet)
|
||
|
||
workbook.save(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:
|
||
sheet.append(SUMMARY_HEADERS)
|
||
for result in results:
|
||
employee = result.employee
|
||
row = [
|
||
employee.name,
|
||
employee.department,
|
||
employee.position,
|
||
employee.attendance_group,
|
||
result.salary_month,
|
||
_salary_mode_label(result.salary_mode),
|
||
result.attendance_days,
|
||
result.absence_days,
|
||
result.actual_work_hours,
|
||
employee.summary_fields.get("休息天数", 0),
|
||
employee.summary_fields.get("加班总时长", 0),
|
||
result.punch_overtime_hours,
|
||
result.leave_hours,
|
||
result.remaining_overtime_hours,
|
||
result.workday_overtime_hours,
|
||
result.weekend_overtime_hours,
|
||
result.holiday_overtime_hours,
|
||
result.minor_late_count,
|
||
result.major_late_count,
|
||
result.penalized_late_count,
|
||
result.late_deduction,
|
||
result.missing_card_count,
|
||
result.missing_card_deduction,
|
||
result.total_deduction,
|
||
result.base_salary,
|
||
result.base_pay,
|
||
result.commission_amount,
|
||
result.overtime_rate,
|
||
result.weekend_overtime_rate,
|
||
result.holiday_overtime_rate,
|
||
result.overtime_pay,
|
||
result.gross_salary,
|
||
result.net_salary,
|
||
result.note,
|
||
]
|
||
sheet.append(row)
|
||
|
||
|
||
def _write_daily_details(sheet, results: list[PayrollResult]) -> None:
|
||
sheet.append(DAILY_HEADERS)
|
||
for result in results:
|
||
for record in result.employee.daily_records:
|
||
sheet.append(
|
||
[
|
||
result.employee.name,
|
||
record.work_date,
|
||
record.raw_text,
|
||
_format_time(record.first_punch),
|
||
_format_time(record.last_punch),
|
||
record.punch_overtime_hours,
|
||
",".join(str(minutes) for minutes in record.late_minutes),
|
||
record.missing_card_count,
|
||
record.leave_hours_in_cell,
|
||
";".join(iter_leave_event_descriptions(record.leave_events)),
|
||
]
|
||
)
|
||
|
||
|
||
def _write_rules(sheet, config: AppConfig) -> None:
|
||
rows = [
|
||
("下班时间", config.attendance.off_work_time),
|
||
("加班取整分钟", config.attendance.overtime_round_minutes),
|
||
("加班计算", "以下班打卡时间为准,超过下班时间后按取整分钟向下取整"),
|
||
("5分钟内迟到免扣次数", config.attendance.minor_late_free_times),
|
||
("迟到分钟阈值", config.attendance.minor_late_minutes),
|
||
("迟到扣款/次", config.attendance.late_penalty),
|
||
("缺卡扣款/次", config.attendance.missing_card_penalty),
|
||
("请假/调休关键字", "、".join(config.attendance.leave_keywords)),
|
||
("剩余加班工时", "打卡推算加班工时 - 去重后的请假/调休工时"),
|
||
]
|
||
sheet.append(["规则", "值"])
|
||
for row in rows:
|
||
sheet.append(row)
|
||
|
||
|
||
def _style_sheet(sheet) -> None:
|
||
header_fill = PatternFill("solid", fgColor="1F4E78")
|
||
header_font = Font(color="FFFFFF", bold=True)
|
||
for cell in sheet[1]:
|
||
cell.fill = header_fill
|
||
cell.font = header_font
|
||
cell.alignment = Alignment(horizontal="center", vertical="center")
|
||
|
||
sheet.freeze_panes = "A2"
|
||
for row in sheet.iter_rows(min_row=2):
|
||
for cell in row:
|
||
cell.alignment = Alignment(vertical="top", wrap_text=True)
|
||
|
||
for col_idx, cells in enumerate(sheet.columns, start=1):
|
||
max_length = 0
|
||
for cell in cells:
|
||
value = cell.value
|
||
if value is None:
|
||
continue
|
||
text = str(value)
|
||
max_length = max(max_length, min(len(text), 45))
|
||
sheet.column_dimensions[get_column_letter(col_idx)].width = max(10, max_length + 2)
|
||
|
||
|
||
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)
|