feat: 凭证生成、成本分析、AI问答、前端页面、集成测试与E2E测试

- 后端: 凭证生成引擎、金蝶导出器、凭证模板服务
- 后端: 成本分析服务、AI问答服务
- 后端: 科目映射CRUD API、分析API、QA API
- 后端: 集成测试(认证/任务/凭证) 49个测试全部通过
- 前端: 凭证管理、成本分析、导出中心、知识库、系统设置页面
- 前端: AuthGuard认证守卫、Dashboard AI聊天功能
- 前端: Playwright E2E测试 16 passed, 1 skipped
- 基础设施: Docker Compose、Nginx反向代理、.env.example
- 文档: 用户手册、管理员手册、发布检查清单
This commit is contained in:
selfrelease
2026-07-07 21:21:29 +08:00
parent feebbb10ac
commit 5278190750
47 changed files with 6567 additions and 156 deletions
+286
View File
@@ -0,0 +1,286 @@
"""
凭证生成引擎
根据对账数据和科目映射生成会计凭证
"""
import logging
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.account_mapping import AccountMapping
from app.models.voucher import Voucher, VoucherStatus
from app.models.reconciliation_task import ReconciliationTask
from app.services.voucher.template import VoucherTemplate, DEFAULT_ACCOUNT_TEMPLATES
logger = logging.getLogger(__name__)
class VoucherEntry:
"""凭证分录"""
def __init__(
self,
account_code: str,
account_name: str,
debit_amount: float = 0.0,
credit_amount: float = 0.0,
summary: str = "",
department: str = "",
):
self.account_code = account_code
self.account_name = account_name
self.debit_amount = debit_amount
self.credit_amount = credit_amount
self.summary = summary
self.department = department
def to_dict(self) -> Dict[str, Any]:
return {
"account_code": self.account_code,
"account_name": self.account_name,
"debit_amount": self.debit_amount,
"credit_amount": self.credit_amount,
"summary": self.summary,
"department": self.department,
}
class VoucherGeneratorService:
"""凭证生成引擎"""
def __init__(self, db: AsyncSession):
self.db = db
async def generate_voucher(
self,
task_id: int,
company_id: int,
period: str = "",
) -> Voucher:
"""
根据对账任务数据生成会计凭证
Args:
task_id: 对账任务ID
company_id: 企业ID
period: 会计期间(如 2024-01),为空则从任务中获取
Returns:
生成的凭证对象
"""
# 1. 加载对账数据
task = await self.db.get(ReconciliationTask, task_id)
if not task:
raise ValueError(f"任务不存在: {task_id}")
if not period:
period = task.period
# 2. 获取科目映射
mappings = await self._get_account_mappings(company_id)
# 3. 加载清洗后的数据
records = await self._load_cleaned_data(task_id)
# 4. 按字段汇总金额
field_totals = self._aggregate_by_field(records)
# 5. 生成凭证分录
entries = self._generate_entries(field_totals, mappings)
# 6. 计算合计
total_debit = sum(e.debit_amount for e in entries)
total_credit = sum(e.credit_amount for e in entries)
# 7. 生成凭证编号
voucher_number = f"PAY-{period.replace('-', '')}-{task_id:04d}"
# 8. 创建凭证
voucher = Voucher(
company_id=company_id,
task_id=task_id,
voucher_number=voucher_number,
voucher_date=datetime.now().strftime("%Y-%m-%d"),
period=period,
summary=f"{period} 工资薪酬凭证",
entries=[e.to_dict() for e in entries],
total_debit=total_debit,
total_credit=total_credit,
status=VoucherStatus.DRAFT,
)
self.db.add(voucher)
await self.db.commit()
await self.db.refresh(voucher)
logger.info(f"凭证已生成: {voucher.voucher_number}, 借方合计={total_debit}, 贷方合计={total_credit}")
return voucher
async def get_voucher(self, task_id: int) -> Optional[Voucher]:
"""获取任务的凭证"""
result = await self.db.execute(
select(Voucher)
.where(Voucher.task_id == task_id)
.order_by(Voucher.created_at.desc())
.limit(1)
)
return result.scalar_one_or_none()
async def confirm_voucher(self, voucher_id: int, user_id: int) -> Optional[Voucher]:
"""确认凭证"""
voucher = await self.db.get(Voucher, voucher_id)
if not voucher:
return None
voucher.status = VoucherStatus.CONFIRMED
voucher.confirmed_by = user_id
voucher.confirmed_at = datetime.utcnow()
await self.db.commit()
await self.db.refresh(voucher)
return voucher
async def _get_account_mappings(self, company_id: int) -> Dict[str, Dict]:
"""
获取企业的科目映射,不存在则使用默认模板
Args:
company_id: 企业ID
Returns:
标准字段 -> 科目映射配置
"""
result = await self.db.execute(
select(AccountMapping).where(
AccountMapping.company_id == company_id,
AccountMapping.is_active == True,
)
)
db_mappings = result.scalars().all()
# 先用默认模板
mappings = dict(DEFAULT_ACCOUNT_TEMPLATES)
# 用数据库中的映射覆盖
for m in db_mappings:
mappings[m.standard_field] = {
"debit_account": m.debit_account,
"debit_account_name": m.debit_account_name,
"credit_account": m.credit_account,
"credit_account_name": m.credit_account_name,
"cost_center": m.cost_center or "",
}
return mappings
async def _load_cleaned_data(self, task_id: int) -> List[Dict[str, Any]]:
"""加载清洗后的数据"""
task = await self.db.get(ReconciliationTask, task_id)
if not task:
return []
if task.reconciliation_result and isinstance(task.reconciliation_result, dict):
records = task.reconciliation_result.get("records", [])
if records:
return records
return []
def _aggregate_by_field(self, records: List[Dict[str, Any]]) -> Dict[str, float]:
"""
按标准字段汇总金额
Args:
records: 清洗后的数据列表
Returns:
标准字段 -> 总金额
"""
totals: Dict[str, float] = {}
cost_fields = [
"基本工资", "奖金", "补贴", "加班费", "应发工资",
"养老保险(公司)", "医疗保险(公司)", "失业保险(公司)", "公积金(公司)",
"养老保险", "医疗保险", "失业保险", "公积金",
"应缴个税", "实发工资",
]
for record in records:
for field in cost_fields:
value = float(record.get(field, 0) or 0)
if value != 0:
totals[field] = totals.get(field, 0.0) + value
return totals
def _generate_entries(
self,
field_totals: Dict[str, float],
mappings: Dict[str, Dict],
) -> List[VoucherEntry]:
"""
根据字段汇总和科目映射生成凭证分录
Args:
field_totals: 字段金额汇总
mappings: 科目映射
Returns:
凭证分录列表
"""
entries: List[VoucherEntry] = []
for field, amount in field_totals.items():
if abs(amount) < 0.01:
continue
template = mappings.get(field)
if not template:
logger.warning(f"字段 '{field}' 无科目映射,跳过")
continue
# 借方分录
entries.append(VoucherEntry(
account_code=template["debit_account"],
account_name=template["debit_account_name"],
debit_amount=amount,
summary=f"{field}",
))
# 贷方分录
entries.append(VoucherEntry(
account_code=template["credit_account"],
account_name=template["credit_account_name"],
credit_amount=amount,
summary=f"{field}",
))
# 合并相同科目的分录
entries = self._merge_entries(entries)
return entries
def _merge_entries(self, entries: List[VoucherEntry]) -> List[VoucherEntry]:
"""合并相同科目的分录"""
merged: Dict[Tuple[str, str], VoucherEntry] = {}
for entry in entries:
key = (entry.account_code, "debit" if entry.debit_amount > 0 else "credit")
if key in merged:
if entry.debit_amount > 0:
merged[key].debit_amount += entry.debit_amount
else:
merged[key].credit_amount += entry.credit_amount
merged[key].summary += f", {entry.summary}"
else:
merged[key] = VoucherEntry(
account_code=entry.account_code,
account_name=entry.account_name,
debit_amount=entry.debit_amount,
credit_amount=entry.credit_amount,
summary=entry.summary,
)
return list(merged.values())
@@ -0,0 +1,246 @@
"""
金蝶格式导出服务
将凭证导出为金蝶K3/星空可导入的格式
"""
import csv
import io
import logging
from datetime import datetime
from typing import List, Optional
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.voucher import Voucher
logger = logging.getLogger(__name__)
class KingdeeExporterService:
"""金蝶格式导出服务"""
def __init__(self, db: AsyncSession):
self.db = db
async def export_voucher(
self,
voucher_id: int,
format_type: str = "csv",
) -> StreamingResponse:
"""
导出凭证为金蝶格式
Args:
voucher_id: 凭证ID
format_type: 导出格式 (csv/excel)
Returns:
StreamingResponse
"""
voucher = await self.db.get(Voucher, voucher_id)
if not voucher:
raise ValueError(f"凭证不存在: {voucher_id}")
if format_type == "excel":
return self._export_excel(voucher)
else:
return self._export_csv(voucher)
async def export_task_vouchers(
self,
task_id: int,
format_type: str = "csv",
) -> StreamingResponse:
"""
导出任务的所有凭证
Args:
task_id: 任务ID
format_type: 导出格式
Returns:
StreamingResponse
"""
result = await self.db.execute(
select(Voucher).where(Voucher.task_id == task_id)
)
vouchers = list(result.scalars().all())
if not vouchers:
raise ValueError(f"任务 {task_id} 无凭证")
if len(vouchers) == 1:
return await self.export_voucher(vouchers[0].id, format_type)
# 多凭证导出
if format_type == "excel":
return self._export_multiple_excel(vouchers)
else:
return self._export_multiple_csv(vouchers)
def _export_csv(self, voucher: Voucher) -> StreamingResponse:
"""
金蝶K3 CSV导入格式
格式: 凭证日期, 凭证号, 摘要, 科目代码, 科目名称, 借方金额, 贷方金额, 制单人
"""
output = io.StringIO()
writer = csv.writer(output)
# 金蝶K3导入格式头
writer.writerow([
"凭证日期", "凭证字", "凭证号", "摘要",
"科目代码", "科目名称",
"借方金额", "贷方金额",
"制单人", "审核人",
])
entries = voucher.entries or []
for i, entry in enumerate(entries):
writer.writerow([
voucher.voucher_date,
"",
voucher.voucher_number,
entry.get("summary", voucher.summary),
entry.get("account_code", ""),
entry.get("account_name", ""),
f'{entry.get("debit_amount", 0):.2f}',
f'{entry.get("credit_amount", 0):.2f}',
"AI助手",
"",
])
output.seek(0)
# 转为 bytes
content = output.getvalue().encode("utf-8-sig") # BOM for Excel compatibility
return StreamingResponse(
io.BytesIO(content),
media_type="text/csv",
headers={
"Content-Disposition": f'attachment; filename="voucher_{voucher.voucher_number}.csv"',
},
)
def _export_excel(self, voucher: Voucher) -> StreamingResponse:
"""导出为 Excel 格式"""
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.title = "凭证"
# 标题行
ws.append(["凭证编号", voucher.voucher_number])
ws.append(["凭证日期", voucher.voucher_date])
ws.append(["会计期间", voucher.period])
ws.append(["摘要", voucher.summary])
ws.append([])
# 分录表头
ws.append(["序号", "科目代码", "科目名称", "摘要", "借方金额", "贷方金额"])
entries = voucher.entries or []
for i, entry in enumerate(entries, 1):
ws.append([
i,
entry.get("account_code", ""),
entry.get("account_name", ""),
entry.get("summary", ""),
entry.get("debit_amount", 0),
entry.get("credit_amount", 0),
])
# 合计行
ws.append([])
ws.append(["", "", "", "合计", voucher.total_debit, voucher.total_credit])
output = io.BytesIO()
wb.save(output)
output.seek(0)
return StreamingResponse(
output,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": f'attachment; filename="voucher_{voucher.voucher_number}.xlsx"',
},
)
def _export_multiple_csv(self, vouchers: List[Voucher]) -> StreamingResponse:
"""多凭证 CSV 导出"""
output = io.StringIO()
writer = csv.writer(writer)
writer.writerow([
"凭证日期", "凭证字", "凭证号", "摘要",
"科目代码", "科目名称",
"借方金额", "贷方金额",
])
for voucher in vouchers:
entries = voucher.entries or []
for entry in entries:
writer.writerow([
voucher.voucher_date,
"",
voucher.voucher_number,
entry.get("summary", voucher.summary),
entry.get("account_code", ""),
entry.get("account_name", ""),
f'{entry.get("debit_amount", 0):.2f}',
f'{entry.get("credit_amount", 0):.2f}',
])
output.seek(0)
content = output.getvalue().encode("utf-8-sig")
return StreamingResponse(
io.BytesIO(content),
media_type="text/csv",
headers={
"Content-Disposition": f'attachment; filename="vouchers_batch.csv"',
},
)
def _export_multiple_excel(self, vouchers: List[Voucher]) -> StreamingResponse:
"""多凭证 Excel 导出"""
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.title = "凭证汇总"
ws.append([
"凭证日期", "凭证号", "会计期间", "摘要",
"科目代码", "科目名称",
"借方金额", "贷方金额",
])
for voucher in vouchers:
entries = voucher.entries or []
for entry in entries:
ws.append([
voucher.voucher_date,
voucher.voucher_number,
voucher.period,
entry.get("summary", voucher.summary),
entry.get("account_code", ""),
entry.get("account_name", ""),
entry.get("debit_amount", 0),
entry.get("credit_amount", 0),
])
output = io.BytesIO()
wb.save(output)
output.seek(0)
return StreamingResponse(
output,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": f'attachment; filename="vouchers_batch.xlsx"',
},
)
+129
View File
@@ -0,0 +1,129 @@
"""
凭证模板服务
定义标准字段到会计科目的默认映射模板
"""
from typing import Dict, List
# 默认科目映射模板(标准字段 -> 借方科目/贷方科目)
DEFAULT_ACCOUNT_TEMPLATES: Dict[str, Dict] = {
"基本工资": {
"debit_account": "6601.01",
"debit_account_name": "管理费用-工资",
"credit_account": "2211.01",
"credit_account_name": "应付职工薪酬-工资",
},
"奖金": {
"debit_account": "6601.01",
"debit_account_name": "管理费用-工资",
"credit_account": "2211.01",
"credit_account_name": "应付职工薪酬-工资",
},
"补贴": {
"debit_account": "6601.01",
"debit_account_name": "管理费用-工资",
"credit_account": "2211.01",
"credit_account_name": "应付职工薪酬-工资",
},
"加班费": {
"debit_account": "6601.01",
"debit_account_name": "管理费用-工资",
"credit_account": "2211.01",
"credit_account_name": "应付职工薪酬-工资",
},
"应发工资": {
"debit_account": "6601.01",
"debit_account_name": "管理费用-工资",
"credit_account": "2211.01",
"credit_account_name": "应付职工薪酬-工资",
},
"养老保险(公司)": {
"debit_account": "6601.02",
"debit_account_name": "管理费用-社保",
"credit_account": "2211.02",
"credit_account_name": "应付职工薪酬-社保",
},
"医疗保险(公司)": {
"debit_account": "6601.02",
"debit_account_name": "管理费用-社保",
"credit_account": "2211.02",
"credit_account_name": "应付职工薪酬-社保",
},
"失业保险(公司)": {
"debit_account": "6601.02",
"debit_account_name": "管理费用-社保",
"credit_account": "2211.02",
"credit_account_name": "应付职工薪酬-社保",
},
"公积金(公司)": {
"debit_account": "6601.03",
"debit_account_name": "管理费用-公积金",
"credit_account": "2211.03",
"credit_account_name": "应付职工薪酬-公积金",
},
"养老保险": {
"debit_account": "2211.01",
"debit_account_name": "应付职工薪酬-工资",
"credit_account": "2221.01",
"credit_account_name": "其他应付款-养老",
},
"医疗保险": {
"debit_account": "2211.01",
"debit_account_name": "应付职工薪酬-工资",
"credit_account": "2221.02",
"credit_account_name": "其他应付款-医疗",
},
"失业保险": {
"debit_account": "2211.01",
"debit_account_name": "应付职工薪酬-工资",
"credit_account": "2221.03",
"credit_account_name": "其他应付款-失业",
},
"公积金": {
"debit_account": "2211.01",
"debit_account_name": "应付职工薪酬-工资",
"credit_account": "2221.04",
"credit_account_name": "其他应付款-公积金",
},
"应缴个税": {
"debit_account": "2211.01",
"debit_account_name": "应付职工薪酬-工资",
"credit_account": "2221.05",
"credit_account_name": "应交税费-个人所得税",
},
"实发工资": {
"debit_account": "2211.01",
"debit_account_name": "应付职工薪酬-工资",
"credit_account": "1001.01",
"credit_account_name": "银行存款",
},
}
class VoucherTemplate:
"""凭证模板服务"""
@staticmethod
def get_default_template(standard_field: str) -> Dict:
"""
获取标准字段的默认科目映射
Args:
standard_field: 标准字段名
Returns:
科目映射配置
"""
return DEFAULT_ACCOUNT_TEMPLATES.get(standard_field, {})
@staticmethod
def get_all_templates() -> Dict[str, Dict]:
"""获取所有默认模板"""
return DEFAULT_ACCOUNT_TEMPLATES
@staticmethod
def get_template_fields() -> List[str]:
"""获取所有有模板的字段列表"""
return list(DEFAULT_ACCOUNT_TEMPLATES.keys())