Files
selfrelease 5278190750 feat: 凭证生成、成本分析、AI问答、前端页面、集成测试与E2E测试
- 后端: 凭证生成引擎、金蝶导出器、凭证模板服务
- 后端: 成本分析服务、AI问答服务
- 后端: 科目映射CRUD API、分析API、QA API
- 后端: 集成测试(认证/任务/凭证) 49个测试全部通过
- 前端: 凭证管理、成本分析、导出中心、知识库、系统设置页面
- 前端: AuthGuard认证守卫、Dashboard AI聊天功能
- 前端: Playwright E2E测试 16 passed, 1 skipped
- 基础设施: Docker Compose、Nginx反向代理、.env.example
- 文档: 用户手册、管理员手册、发布检查清单
2026-07-07 21:21:29 +08:00

287 lines
8.8 KiB
Python

"""
凭证生成引擎
根据对账数据和科目映射生成会计凭证
"""
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())