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:
@@ -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())
|
||||
Reference in New Issue
Block a user