feat: change the main logic of evaluation

This commit is contained in:
2025-11-16 02:49:23 +08:00
parent 77dfe9b6ef
commit a2f48b6bf4
9 changed files with 1103 additions and 620 deletions
+412 -114
View File
@@ -5,6 +5,7 @@ from decimal import Decimal
import os
from django.db import transaction
from django.db.models import Sum, Count, Q
from django.http import HttpResponse
from django.utils import timezone
from django_filters.rest_framework import DjangoFilterBackend
@@ -15,72 +16,243 @@ from rest_framework.response import Response
from rest_framework_simplejwt.authentication import JWTAuthentication
from openpyxl import load_workbook
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill
from finance.models import Department
from personnel.models import Personnel
from .filters import EvaluationRecordFilter
from .models import EvaluationRecord
from .serializers import EvaluationRecordSerializer
from .serializers import EvaluationRecordSerializer, PersonnelSummarySerializer
class EvaluationRecordViewSet(viewsets.ModelViewSet):
"""考评记录视图集"""
authentication_classes = [JWTAuthentication]
queryset = EvaluationRecord.objects.select_related('department', 'personnel').all()
queryset = EvaluationRecord.objects.select_related('department').all()
serializer_class = EvaluationRecordSerializer
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
filterset_class = EvaluationRecordFilter
search_fields = ['item_description', 'remarks', 'personnel__name', 'department__name']
search_fields = ['item_description', 'remarks', 'personnel', 'department__name', 'grade']
ordering_fields = ['evaluation_date', 'created_at', 'total_score', 'bonus_score', 'deduction_score']
ordering = ['-evaluation_date', '-created_at']
@action(detail=False, methods=['get'], url_path='personnel-summary')
def personnel_summary(self, request, *args, **kwargs):
"""获取人员汇总列表"""
queryset = self.filter_queryset(self.get_queryset())
# 按人员、部门、年级分组汇总
summary_data = queryset.values('personnel', 'department__name', 'grade').annotate(
total_bonus=Sum('bonus_score'),
total_deduction=Sum('deduction_score'),
bonus_count=Count('id', filter=Q(bonus_score__gt=0)),
deduction_count=Count('id', filter=Q(deduction_score__gt=0)),
).order_by('department__name', 'personnel')
# 计算总分
result = []
for item in summary_data:
total_score = (item['total_bonus'] or Decimal('0')) - (item['total_deduction'] or Decimal('0'))
result.append({
'personnel': item['personnel'],
'department_name': item['department__name'] or '',
'grade': item['grade'] or '',
'total_bonus': item['total_bonus'] or Decimal('0'),
'total_deduction': item['total_deduction'] or Decimal('0'),
'total_score': total_score,
'bonus_count': item['bonus_count'],
'deduction_count': item['deduction_count'],
})
serializer = PersonnelSummarySerializer(result, many=True)
return Response(serializer.data)
@action(detail=False, methods=['get'], url_path='personnel-records')
def personnel_records(self, request, *args, **kwargs):
"""获取某个人员的所有记录"""
personnel_name = request.query_params.get('personnel')
if not personnel_name:
return Response({'detail': '缺少personnel参数'}, status=status.HTTP_400_BAD_REQUEST)
queryset = self.get_queryset().filter(personnel=personnel_name).order_by('-evaluation_date')
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
@action(detail=False, methods=['delete'], url_path='delete-personnel')
def delete_personnel(self, request, *args, **kwargs):
"""删除某个人员的所有记录"""
personnel_name = request.query_params.get('personnel')
department_name = request.query_params.get('department')
grade = request.query_params.get('grade', '')
if not personnel_name:
return Response({'detail': '缺少personnel参数'}, status=status.HTTP_400_BAD_REQUEST)
# 构建查询条件
filter_params = {'personnel': personnel_name}
if department_name:
filter_params['department__name'] = department_name
if grade:
filter_params['grade'] = grade
# 查找要删除的记录
queryset = self.get_queryset().filter(**filter_params)
deleted_count = queryset.count()
if deleted_count == 0:
return Response({'detail': '未找到要删除的记录'}, status=status.HTTP_404_NOT_FOUND)
# 删除记录
queryset.delete()
return Response({
'detail': f'成功删除 {deleted_count} 条记录',
'deleted_count': deleted_count
}, status=status.HTTP_200_OK)
@action(detail=False, methods=['get'], url_path='export')
def export_records(self, request, *args, **kwargs):
"""导出人员考评记录,每个人一个表格"""
queryset = self.filter_queryset(self.get_queryset())
if not queryset.exists():
return Response({'detail': '暂无数据可导出'}, status=status.HTTP_400_BAD_REQUEST)
filename = f'evaluation_records_{timezone.now().strftime("%Y%m%d_%H%M%S")}.csv'
response = HttpResponse(content_type='text/csv; charset=utf-8')
response['Content-Disposition'] = f'attachment; filename="{filename}"'
response.write('\ufeff')
writer = csv.writer(response)
headers = [
'id',
'department_id',
'department_name',
'personnel_id',
'personnel_name',
'item_description',
'bonus_score',
'deduction_score',
'total_score',
'evaluation_date',
'remarks',
'created_at',
'updated_at',
]
writer.writerow(headers)
# 创建Excel工作簿
wb = Workbook()
wb.remove(wb.active) # 删除默认工作表
# 按人员分组
personnel_groups = {}
for record in queryset:
writer.writerow([
record.id,
record.department_id,
record.department.name if record.department else '',
record.personnel_id,
record.personnel.name if record.personnel else '',
record.item_description,
f'{record.bonus_score:.2f}',
f'{record.deduction_score:.2f}',
f'{record.total_score:.2f}',
record.evaluation_date.isoformat() if record.evaluation_date else '',
record.remarks or '',
record.created_at.isoformat(sep=' ') if record.created_at else '',
record.updated_at.isoformat(sep=' ') if record.updated_at else '',
])
personnel_key = f"{record.personnel}_{record.department.name if record.department else ''}"
if personnel_key not in personnel_groups:
personnel_groups[personnel_key] = {
'personnel': record.personnel,
'department_name': record.department.name if record.department else '',
'grade': record.grade or '',
'records': []
}
personnel_groups[personnel_key]['records'].append(record)
# 样式定义
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
header_font = Font(bold=True, color="FFFFFF")
summary_fill = PatternFill(start_color="DCE6F1", end_color="DCE6F1", fill_type="solid")
summary_font = Font(bold=True)
# 为每个人创建表格
for idx, (key, group) in enumerate(personnel_groups.items(), 1):
ws = wb.create_sheet(title=f"{group['personnel']}_{idx}")
# 计算统计信息
total_bonus = sum(record.bonus_score for record in group['records'])
total_deduction = sum(record.deduction_score for record in group['records'])
total_score = total_bonus - total_deduction
bonus_count = sum(1 for record in group['records'] if record.bonus_score > 0)
deduction_count = sum(1 for record in group['records'] if record.deduction_score > 0)
# 基本信息行
ws.append(['基本信息'])
ws.append(['部门', group['department_name']])
ws.append(['姓名', group['personnel']])
ws.append(['年级', group['grade']])
ws.append([])
# 统计信息行
ws.append(['统计信息'])
ws.append(['总加分', f'{total_bonus:.2f}'])
ws.append(['总扣分', f'{total_deduction:.2f}'])
ws.append(['加分次数', bonus_count])
ws.append(['扣分次数', deduction_count])
ws.append([])
# 记录表头
headers = ['扣分/加分说明', '考评时间', '分值', '备注']
ws.append(headers)
# 设置表头样式
for col in range(1, len(headers) + 1):
cell = ws.cell(row=ws.max_row, column=col)
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center', vertical='center')
# 添加记录数据
for record in sorted(group['records'], key=lambda x: x.evaluation_date, reverse=True):
score_value = ''
if record.bonus_score > 0:
score_value = f"+{record.bonus_score:.2f}"
elif record.deduction_score > 0:
score_value = f"-{record.deduction_score:.2f}"
ws.append([
record.item_description,
record.evaluation_date.strftime('%Y-%m-%d') if record.evaluation_date else '',
score_value,
record.remarks or '',
])
# 设置列宽
ws.column_dimensions['A'].width = 30
ws.column_dimensions['B'].width = 15
ws.column_dimensions['C'].width = 12
ws.column_dimensions['D'].width = 30
# 保存到内存
output = io.BytesIO()
wb.save(output)
output.seek(0)
filename = f'人员考评记录_{timezone.now().strftime("%Y%m%d_%H%M%S")}.xlsx'
response = HttpResponse(
output.getvalue(),
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
response['Content-Disposition'] = f'attachment; filename="{filename}"'
return response
@action(detail=False, methods=['get'], url_path='download-template')
def download_template(self, request, *args, **kwargs):
"""下载导入样表"""
# 创建Excel工作簿
wb = Workbook()
ws = wb.active
ws.title = '人员导入样表'
# 表头
headers = ['部门', '年级', '姓名']
ws.append(headers)
# 设置表头样式
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
header_font = Font(bold=True, color="FFFFFF")
for col in range(1, len(headers) + 1):
cell = ws.cell(row=1, column=col)
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center', vertical='center')
# 添加示例数据行
ws.append(['程序部', '24', '张三'])
ws.append(['Web部', '23', '李四'])
# 设置列宽
ws.column_dimensions['A'].width = 20
ws.column_dimensions['B'].width = 15
ws.column_dimensions['C'].width = 15
# 保存到内存
output = io.BytesIO()
wb.save(output)
output.seek(0)
filename = f'人员导入样表_{timezone.now().strftime("%Y%m%d")}.xlsx'
response = HttpResponse(
output.getvalue(),
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
response['Content-Disposition'] = f'attachment; filename="{filename}"'
return response
@action(detail=False, methods=['post'], url_path='import')
@@ -112,62 +284,205 @@ class EvaluationRecordViewSet(viewsets.ModelViewSet):
if not records_data:
return Response({'detail': '导入文件没有数据'}, status=status.HTTP_400_BAD_REQUEST)
reader_fieldnames = records_data[0].keys() if isinstance(records_data[0], dict) else []
required_columns = {
'id',
'department_id',
'department_name',
'personnel_id',
'personnel_name',
'item_description',
'bonus_score',
'deduction_score',
'evaluation_date',
# 字段名映射:中文列名 -> 英文字段名
field_mapping = {
'部门': 'department_name',
'所属部门': 'department_name',
'department_name': 'department_name',
'姓名': 'personnel_name',
'personnel_name': 'personnel_name',
'年级': 'grade',
'grade': 'grade',
'扣分/加分说明': 'item_description',
'item_description': 'item_description',
'加分': 'bonus_score',
'bonus_score': 'bonus_score',
'扣分': 'deduction_score',
'deduction_score': 'deduction_score',
'考评日期': 'evaluation_date',
'evaluation_date': 'evaluation_date',
'考评时间': 'evaluation_date',
'备注': 'remarks',
'remarks': 'remarks',
'分值': 'score',
}
missing_columns = required_columns - set(reader_fieldnames)
if missing_columns:
return Response(
{'detail': f'缺少必要的列: {", ".join(sorted(missing_columns))}'},
status=status.HTTP_400_BAD_REQUEST,
)
reader_fieldnames = records_data[0].keys() if isinstance(records_data[0], dict) else []
# 获取实际存在的字段名(可能是中文或英文)
available_field_keys = set()
for field_name in reader_fieldnames:
# 去除首尾空格后查找映射
field_name_clean = field_name.strip() if field_name else ''
mapped_key = field_mapping.get(field_name_clean)
if mapped_key:
available_field_keys.add(mapped_key)
# 检查是样表格式(只有部门、年级、姓名)还是完整格式(有考评记录)
# 样表格式:必须包含部门名和姓名,且不包含扣分/加分说明和考评日期
is_template_format = (
'department_name' in available_field_keys and
'personnel_name' in available_field_keys and
'item_description' not in available_field_keys and
'evaluation_date' not in available_field_keys
)
if not is_template_format:
# 完整格式需要检查必要的列
required_fields = {'department_name', 'personnel_name', 'item_description',
'evaluation_date'}
missing_fields = required_fields - available_field_keys
if missing_fields:
# 将英文字段名转换为中文显示
field_name_map = {
'department_name': '部门',
'personnel_name': '姓名',
'item_description': '扣分/加分说明',
'evaluation_date': '考评日期',
}
missing_display = [field_name_map.get(f, f) for f in sorted(missing_fields)]
return Response(
{'detail': f'缺少必要的列: {", ".join(missing_display)}'},
status=status.HTTP_400_BAD_REQUEST,
)
def get_field_value(row_dict, field_key):
"""从行数据中获取字段值,支持中英文列名"""
# 收集所有可能的中文列名(去除首尾空格匹配)
possible_keys = [field_key] # 先尝试英文字段名
# 找到所有映射到该字段的中文列名
for chinese, english in field_mapping.items():
if english == field_key:
possible_keys.append(chinese)
# 尝试所有可能的键(包括去除空格后的键)
for key in possible_keys:
# 直接匹配
if key in row_dict:
value = row_dict[key]
if value is not None:
return value
# 去除空格后匹配(忽略键名和值的前后空格)
for row_key in row_dict.keys():
if row_key and str(row_key).strip() == key.strip():
value = row_dict[row_key]
if value is not None:
return value
return None
records_to_create = []
seen_ids = set()
errors = []
skipped_count = 0
for idx, row in enumerate(records_data, start=2):
try:
if not isinstance(row, dict):
raise ValueError('数据格式不正确')
record_id = int(row['id']) if row.get('id') else None
if record_id in seen_ids:
raise ValueError('表中存在重复的记录 ID')
seen_ids.add(record_id)
# 根据 department_name 查找部门(支持中英文列名)
department_name = get_field_value(row, 'department_name')
if department_name is None:
# 显示实际存在的列名,帮助调试
available_keys = list(row.keys())
raise ValueError(f'部门名称不能为空。当前行的列名: {available_keys}')
department_name = str(department_name).strip()
if not department_name:
available_keys = list(row.keys())
raise ValueError(f'部门名称不能为空。当前行的列名: {available_keys}')
department = Department.objects.filter(name=department_name).first()
if not department:
raise ValueError(f'找不到部门: {department_name}')
department = self._resolve_department(row.get('department_id'), row.get('department_name'))
personnel = self._resolve_personnel(row.get('personnel_id'), row.get('personnel_name'))
# personnel_name 直接作为字符串存储(支持中英文列名)
personnel_name = str(get_field_value(row, 'personnel_name') or '').strip()
if not personnel_name:
raise ValueError('人员名称不能为空')
evaluation_date = datetime.strptime(row['evaluation_date'], '%Y-%m-%d').date()
bonus_score = Decimal(row.get('bonus_score') or '0')
deduction_score = Decimal(row.get('deduction_score') or '0')
remarks = row.get('remarks', '')
# 年级(可选)
grade = str(get_field_value(row, 'grade') or '').strip()
created_at = self._parse_datetime(row.get('created_at'))
updated_at = self._parse_datetime(row.get('updated_at'))
# 检查是否已存在相同姓名、年级、部门的人员记录
existing_records = EvaluationRecord.objects.filter(
personnel=personnel_name,
grade=grade,
department=department
).exists()
if existing_records:
skipped_count += 1
continue # 跳过已存在的人员
# 如果是样表格式(只有部门、年级、姓名),创建初始记录(总分为39)
if is_template_format:
evaluation_date = timezone.now().date()
item_description = '初始分数'
bonus_score = Decimal('39')
deduction_score = Decimal('0')
remarks = ''
else:
# 完整格式,从文件中读取
# 解析日期,支持多种格式(支持中英文列名)
evaluation_date_value = get_field_value(row, 'evaluation_date')
if evaluation_date_value is None:
raise ValueError('考评日期不能为空')
# 如果已经是date类型,直接使用
if isinstance(evaluation_date_value, date):
evaluation_date = evaluation_date_value
else:
evaluation_date_str = str(evaluation_date_value).strip()
evaluation_date = None
for date_format in ('%Y-%m-%d', '%Y/%m/%d', '%Y-%m-%d %H:%M:%S', '%Y/%m/%d %H:%M:%S'):
try:
evaluation_date = datetime.strptime(evaluation_date_str, date_format).date()
break
except ValueError:
continue
if evaluation_date is None:
raise ValueError(f'无法解析日期格式: {evaluation_date_str}')
# 获取加分和扣分(支持中英文列名)
# 支持两种格式:1. 分别的加分/扣分列 2. 统一的分值列(+为加分,-为扣分)
score_value = get_field_value(row, 'score')
bonus_score = Decimal('0')
deduction_score = Decimal('0')
if score_value:
# 从分值列解析
score_str = str(score_value).strip()
if score_str.startswith('+'):
bonus_score = Decimal(str(score_str[1:]) or '0')
elif score_str.startswith('-'):
deduction_score = Decimal(str(score_str[1:]) or '0')
else:
# 尝试解析为数字
try:
score_decimal = Decimal(score_str)
if score_decimal >= 0:
bonus_score = score_decimal
else:
deduction_score = abs(score_decimal)
except (ValueError, TypeError):
pass
else:
# 从分别的列读取
bonus_score = Decimal(str(get_field_value(row, 'bonus_score') or '0'))
deduction_score = Decimal(str(get_field_value(row, 'deduction_score') or '0'))
item_description = str(get_field_value(row, 'item_description') or '').strip()
remarks = str(get_field_value(row, 'remarks') or '').strip()
# total_score 会在 save() 方法中自动计算,不需要手动设置
record = EvaluationRecord(
id=record_id,
department=department,
personnel=personnel,
item_description=row.get('item_description', ''),
personnel=personnel_name,
grade=grade,
item_description=item_description,
bonus_score=bonus_score,
deduction_score=deduction_score,
total_score=bonus_score - deduction_score,
evaluation_date=evaluation_date,
remarks=remarks,
created_at=created_at or timezone.now(),
updated_at=updated_at or timezone.now(),
)
records_to_create.append(record)
except Exception as exc: # pylint: disable=broad-except
@@ -176,44 +491,24 @@ class EvaluationRecordViewSet(viewsets.ModelViewSet):
if errors:
return Response({'detail': '导入失败', 'errors': errors}, status=status.HTTP_400_BAD_REQUEST)
if not records_to_create:
skip_msg = f'跳过 {skipped_count} 条已存在的记录' if skipped_count > 0 else ''
return Response(
{'detail': f'没有新数据需要导入。{skip_msg}'},
status=status.HTTP_200_OK
)
with transaction.atomic():
EvaluationRecord.objects.all().delete()
if not is_template_format:
# 完整格式导入时,先删除所有记录(保持原有行为)
EvaluationRecord.objects.all().delete()
EvaluationRecord.objects.bulk_create(records_to_create)
return Response({'detail': f'成功导入 {len(records_to_create)}考评记录'}, status=status.HTTP_200_OK)
skip_msg = f',跳过 {skipped_count}已存在的记录' if skipped_count > 0 else ''
return Response({
'detail': f'成功导入 {len(records_to_create)} 条记录{skip_msg}'
}, status=status.HTTP_200_OK)
@staticmethod
def _parse_datetime(value):
if not value:
return None
for fmt in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%dT%H:%M:%S', '%Y-%m-%d %H:%M:%S.%f', '%Y-%m-%dT%H:%M:%S.%f'):
try:
return datetime.strptime(value, fmt)
except ValueError:
continue
raise ValueError('无法解析日期时间字段')
@staticmethod
def _resolve_department(department_id, department_name):
department = None
if department_id:
department = Department.objects.filter(id=department_id).first()
if not department and department_name:
department = Department.objects.filter(name=department_name).first()
if not department:
raise ValueError('无法匹配部门')
return department
@staticmethod
def _resolve_personnel(personnel_id, personnel_name):
personnel = None
if personnel_id:
personnel = Personnel.objects.filter(id=personnel_id).first()
if not personnel and personnel_name:
personnel = Personnel.objects.filter(name=personnel_name).first()
if not personnel:
raise ValueError('无法匹配人员')
return personnel
@staticmethod
def _read_excel(file_bytes):
@@ -238,13 +533,16 @@ class EvaluationRecordViewSet(viewsets.ModelViewSet):
if not header:
continue
value = row[col_idx] if col_idx < len(row) else None
header_key = header.strip().lower()
header_stripped = header.strip()
header_key = header_stripped.lower()
# 检查是否是日期列(支持中英文列名)
is_date_column = (header_key == 'evaluation_date' or header_stripped == '考评日期')
if isinstance(value, datetime):
value = value.strftime('%Y-%m-%d %H:%M:%S')
elif isinstance(value, date):
value = value.strftime('%Y-%m-%d')
elif isinstance(value, (int, float)) and header_key in {'evaluation_date', 'created_at', 'updated_at'}:
value = EvaluationRecordViewSet._excel_date_to_iso(value, header_key != 'evaluation_date')
elif isinstance(value, (int, float)) and is_date_column:
value = EvaluationRecordViewSet._excel_date_to_iso(value, False)
elif isinstance(value, Decimal):
value = str(value)
elif isinstance(value, float):