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())