Files
s2f/backend/app/services/export_service.py
T
freedakgmail 33b4c734aa feat: 完善前后端核心功能模块
后端:
- 新增认证(auth)、任务(tasks)、映射(mappings)、对账(reconciliation)、异常(exceptions)、导出(exports) API
- 新增核心模块: database, security, permissions, tenant, exceptions, error_handlers
- 新增数据模型: user, company, reconciliation_task, field_mapping, uploaded_file 等
- 新增服务层: ai_recognizer, file_parser, file_storage, mapping, reconciliation 等
- 添加数据库迁移脚本

前端:
- 新增登录页面和仪表盘页面
- 新增任务列表、任务详情、字段映射页面
- 新增异常处理页面和规则设置页面
- 新增 API 代理路由 /api/[...path]
- 新增 UI 组件库 (button, card, dialog, input, table 等)
- 新增 auth 组件 (ProtectedRoute, PermissionGate)
- 新增 layout 组件 (Header, Sidebar)
- 新增 mapping 组件 (FieldMappingTable, AISuggestionPanel)
- 新增 API 客户端和 hooks (useAsync, useToast, usePermission 等)
- 新增状态管理 (auth-store, company-store, ui-store)
- 集成 Tailwind CSS 和 shadcn/ui 组件库

其他:
- 添加 Alembic 数据库迁移配置
- 添加初始化示例数据脚本
- 更新项目文档
2026-07-07 09:04:47 +08:00

353 lines
14 KiB
Python

"""
导出服务
导出对账结果为 Excel 和金蝶凭证格式
"""
import io
from datetime import datetime
from typing import Any, Dict, List, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.reconciliation_task import ReconciliationTask
from app.models.exception_item import ExceptionItem
try:
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
from openpyxl.utils import get_column_letter
OPENPYXL_AVAILABLE = True
except ImportError:
OPENPYXL_AVAILABLE = False
class ExportService:
"""导出服务"""
def __init__(self, db: AsyncSession):
self.db = db
async def export_task_result(self, task_id: int) -> bytes:
"""
导出任务对账结果
Args:
task_id: 任务ID
Returns:
Excel 文件二进制数据
"""
if not OPENPYXL_AVAILABLE:
raise ImportError("openpyxl 未安装")
# 获取任务信息
task = await self.db.get(ReconciliationTask, task_id)
if not task:
raise ValueError("任务不存在")
# 获取异常列表
from sqlalchemy import select
query = select(ExceptionItem).where(ExceptionItem.task_id == task_id)
result = await self.db.execute(query)
exceptions = result.scalars().all()
# 创建工作簿
wb = Workbook()
# Sheet 1: 概览
ws_summary = wb.active
ws_summary.title = "概览"
self._fill_summary_sheet(ws_summary, task, exceptions)
# Sheet 2: 异常列表
ws_exceptions = wb.create_sheet("异常列表")
self._fill_exceptions_sheet(ws_exceptions, exceptions)
# Sheet 3: 异常类型统计
ws_stats = wb.create_sheet("异常统计")
self._fill_stats_sheet(ws_stats, exceptions)
# 保存到字节流
output = io.BytesIO()
wb.save(output)
output.seek(0)
return output.read()
def _fill_summary_sheet(
self,
ws,
task: ReconciliationTask,
exceptions: List[ExceptionItem],
):
"""填充概览Sheet"""
# 标题样式
title_font = Font(bold=True, size=14)
header_font = Font(bold=True)
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
header_font_white = Font(bold=True, color="FFFFFF")
thin_border = Border(
left=Side(style="thin"),
right=Side(style="thin"),
top=Side(style="thin"),
bottom=Side(style="thin"),
)
# 标题
ws["A1"] = f"对账结果汇总 - {task.name}"
ws["A1"].font = title_font
ws.merge_cells("A1:D1")
# 基本信息
ws["A3"] = "任务名称"
ws["B3"] = task.name
ws["A4"] = "对账期间"
ws["B4"] = task.period
ws["A5"] = "创建时间"
ws["B5"] = task.created_at.strftime("%Y-%m-%d %H:%M:%S") if task.created_at else ""
ws["A6"] = "完成时间"
ws["B6"] = task.completed_at.strftime("%Y-%m-%d %H:%M:%S") if task.completed_at else ""
# 统计数据
ws["A8"] = "统计数据"
ws["A8"].font = header_font
ws["A9"] = "总人数"
ws["B9"] = task.total_employees
ws["A10"] = "匹配人数"
ws["B10"] = task.matched_count
ws["A11"] = "异常数量"
ws["B11"] = task.exception_count
ws["A12"] = "匹配率"
ws["B12"] = f"{(task.matched_count / task.total_employees * 100):.1f}%" if task.total_employees > 0 else "0%"
# 设置列宽
ws.column_dimensions["A"].width = 15
ws.column_dimensions["B"].width = 30
def _fill_exceptions_sheet(self, ws, exceptions: List[ExceptionItem]):
"""填充异常列表Sheet"""
# 样式
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
header_font = Font(bold=True, color="FFFFFF")
thin_border = Border(
left=Side(style="thin"),
right=Side(style="thin"),
top=Side(style="thin"),
bottom=Side(style="thin"),
)
# 表头
headers = [
"异常ID", "员工ID", "员工姓名", "异常类型", "严重程度",
"状态", "描述", "工资", "社保", "个税", "银行", "差异金额",
"解决方案", "处理人", "创建时间"
]
for col, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col, value=header)
cell.fill = header_fill
cell.font = header_font
cell.border = thin_border
cell.alignment = Alignment(horizontal="center")
# 数据
for row_idx, exc in enumerate(exceptions, 2):
ws.cell(row=row_idx, column=1, value=exc.id).border = thin_border
ws.cell(row=row_idx, column=2, value=exc.employee_id).border = thin_border
ws.cell(row=row_idx, column=3, value=exc.employee_name).border = thin_border
ws.cell(row=row_idx, column=4, value=exc.exception_type).border = thin_border
ws.cell(row=row_idx, column=5, value=exc.severity).border = thin_border
ws.cell(row=row_idx, column=6, value=exc.status).border = thin_border
ws.cell(row=row_idx, column=7, value=exc.description).border = thin_border
# 金额(保留两位小数)
ws.cell(row=row_idx, column=8, value=exc.salary_amount).border = thin_border
ws.cell(row=row_idx, column=9, value=exc.social_security_amount).border = thin_border
ws.cell(row=row_idx, column=10, value=exc.tax_amount).border = thin_border
ws.cell(row=row_idx, column=11, value=exc.bank_amount).border = thin_border
ws.cell(row=row_idx, column=12, value=exc.difference_amount).border = thin_border
ws.cell(row=row_idx, column=13, value=exc.resolution).border = thin_border
ws.cell(row=row_idx, column=14, value=exc.handler).border = thin_border
ws.cell(row=row_idx, column=15, value=exc.created_at.strftime("%Y-%m-%d %H:%M:%S") if exc.created_at else "").border = thin_border
# 设置列宽
col_widths = [10, 15, 15, 20, 10, 10, 40, 12, 12, 12, 12, 12, 20, 15, 20]
for col, width in enumerate(col_widths, 1):
ws.column_dimensions[get_column_letter(col)].width = width
def _fill_stats_sheet(self, ws, exceptions: List[ExceptionItem]):
"""填充统计Sheet"""
from collections import Counter
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
header_font = Font(bold=True, color="FFFFFF")
thin_border = Border(
left=Side(style="thin"),
right=Side(style="thin"),
top=Side(style="thin"),
bottom=Side(style="thin"),
)
# 按类型统计
ws["A1"] = "按异常类型统计"
ws["A1"].font = Font(bold=True)
ws["A2"] = "类型"
ws["B2"] = "数量"
ws["A2"].fill = header_fill
ws["B2"].fill = header_fill
ws["A2"].font = header_font
ws["B2"].font = header_font
ws["A2"].border = thin_border
ws["B2"].border = thin_border
type_counts = Counter(e.exception_type for e in exceptions)
for row_idx, (exc_type, count) in enumerate(type_counts.items(), 3):
ws.cell(row=row_idx, column=1, value=exc_type).border = thin_border
ws.cell(row=row_idx, column=2, value=count).border = thin_border
# 按严重程度统计
start_row = len(type_counts) + 5
ws.cell(row=start_row, column=1, value="按严重程度统计").font = Font(bold=True)
ws.cell(row=start_row + 1, column=1, value="严重程度")
ws.cell(row=start_row + 1, column=2, value="数量")
ws.cell(row=start_row + 1, column=1).fill = header_fill
ws.cell(row=start_row + 1, column=2).fill = header_fill
ws.cell(row=start_row + 1, column=1).font = header_font
ws.cell(row=start_row + 1, column=2).font = header_font
severity_counts = Counter(e.severity for e in exceptions)
for row_idx, (severity, count) in enumerate(severity_counts.items(), start_row + 2):
ws.cell(row=row_idx, column=1, value=severity).border = thin_border
ws.cell(row=row_idx, column=2, value=count).border = thin_border
# 按状态统计
start_row = start_row + len(severity_counts) + 3
ws.cell(row=start_row, column=1, value="按状态统计").font = Font(bold=True)
ws.cell(row=start_row + 1, column=1, value="状态")
ws.cell(row=start_row + 1, column=2, value="数量")
ws.cell(row=start_row + 1, column=1).fill = header_fill
ws.cell(row=start_row + 1, column=2).fill = header_fill
ws.cell(row=start_row + 1, column=1).font = header_font
ws.cell(row=start_row + 1, column=2).font = header_font
status_counts = Counter(e.status for e in exceptions)
for row_idx, (status, count) in enumerate(status_counts.items(), start_row + 2):
ws.cell(row=row_idx, column=1, value=status).border = thin_border
ws.cell(row=row_idx, column=2, value=count).border = thin_border
async def export_kingdee_voucher(self, task_id: int) -> bytes:
"""
导出金蝶凭证
Args:
task_id: 任务ID
Returns:
Excel 文件二进制数据
"""
if not OPENPYXL_AVAILABLE:
raise ImportError("openpyxl 未安装")
# 获取任务信息
task = await self.db.get(ReconciliationTask, task_id)
if not task:
raise ValueError("任务不存在")
# 获取已解决的异常(排除已忽略的)
from sqlalchemy import select
query = select(ExceptionItem).where(
ExceptionItem.task_id == task_id,
ExceptionItem.status == "resolved"
)
result = await self.db.execute(query)
resolved_exceptions = result.scalars().all()
# 创建工作簿
wb = Workbook()
ws = wb.active
ws.title = "凭证数据"
# 样式
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
header_font = Font(bold=True, color="FFFFFF")
thin_border = Border(
left=Side(style="thin"),
right=Side(style="thin"),
top=Side(style="thin"),
bottom=Side(style="thin"),
)
# 金蝶凭证格式表头
headers = [
"凭证字", "凭证号", "凭证日期", "附单据数",
"摘要", "科目代码", "科目名称",
"借方金额", "贷方金额", "币种", "汇率"
]
for col, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col, value=header)
cell.fill = header_fill
cell.font = header_font
cell.border = thin_border
cell.alignment = Alignment(horizontal="center")
# 填充数据(根据金蝶凭证格式要求)
row_idx = 2
for exc in resolved_exceptions:
# 生成凭证摘要
summary = f"工资对账调整 - {exc.employee_name} ({exc.employee_id})"
# 如果有差异金额,生成借贷分录
if exc.difference_amount and exc.difference_amount != 0:
# 借方分录
ws.cell(row=row_idx, column=1, value="").border = thin_border
ws.cell(row=row_idx, column=2, value="").border = thin_border # 凭证号自动生成
ws.cell(row=row_idx, column=3, value=task.period + "-01").border = thin_border
ws.cell(row=row_idx, column=4, value=1).border = thin_border
ws.cell(row=row_idx, column=5, value=summary).border = thin_border
ws.cell(row=row_idx, column=6, value="").border = thin_border # 科目代码
ws.cell(row=row_idx, column=7, value="待处理").border = thin_border # 科目名称
if exc.difference_amount > 0:
ws.cell(row=row_idx, column=8, value=abs(exc.difference_amount)).border = thin_border
ws.cell(row=row_idx, column=9, value=0).border = thin_border
else:
ws.cell(row=row_idx, column=8, value=0).border = thin_border
ws.cell(row=row_idx, column=9, value=abs(exc.difference_amount)).border = thin_border
ws.cell(row=row_idx, column=10, value="人民币").border = thin_border
ws.cell(row=row_idx, column=11, value=1).border = thin_border
row_idx += 1
# 设置列宽
col_widths = [10, 12, 15, 10, 40, 15, 20, 15, 15, 10, 10]
for col, width in enumerate(col_widths, 1):
ws.column_dimensions[get_column_letter(col)].width = width
# 保存到字节流
output = io.BytesIO()
wb.save(output)
output.seek(0)
return output.read()
# 便捷函数
async def export_task_result(db: AsyncSession, task_id: int) -> bytes:
"""导出任务对账结果"""
service = ExportService(db)
return await service.export_task_result(task_id)
async def export_kingdee_voucher(db: AsyncSession, task_id: int) -> bytes:
"""导出金蝶凭证"""
service = ExportService(db)
return await service.export_kingdee_voucher(task_id)