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,2 @@
|
||||
"""
|
||||
"""
|
||||
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
集成测试公共夹具
|
||||
|
||||
使用同步 TestClient + Mock AsyncSession 进行 API 集成测试
|
||||
避免 AsyncClient + ASGITransport 在 pytest-asyncio 下的死锁问题
|
||||
"""
|
||||
|
||||
from typing import AsyncGenerator
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db() -> AsyncMock:
|
||||
"""
|
||||
创建 Mock AsyncSession
|
||||
|
||||
返回一个 AsyncMock 对象,模拟异步数据库会话
|
||||
"""
|
||||
session = AsyncMock()
|
||||
session.commit = AsyncMock()
|
||||
session.refresh = AsyncMock()
|
||||
session.rollback = AsyncMock()
|
||||
session.close = AsyncMock()
|
||||
session.add = MagicMock()
|
||||
session.delete = AsyncMock()
|
||||
session.get = AsyncMock(return_value=None)
|
||||
result_mock = MagicMock()
|
||||
result_mock.scalars.return_value.all.return_value = []
|
||||
result_mock.scalars.return_value.first.return_value = None
|
||||
result_mock.scalar_one_or_none.return_value = None
|
||||
result_mock.scalar.return_value = 0
|
||||
result_mock.one.return_value = MagicMock()
|
||||
session.execute = AsyncMock(return_value=result_mock)
|
||||
return session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(mock_db: AsyncMock) -> TestClient:
|
||||
"""
|
||||
创建同步测试客户端,覆盖数据库依赖
|
||||
"""
|
||||
|
||||
async def override_get_db() -> AsyncGenerator[AsyncMock, None]:
|
||||
yield mock_db
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
with TestClient(app) as tc:
|
||||
yield tc
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def make_db_result(items: list = None, scalar=None, one=None):
|
||||
"""
|
||||
构造 db.execute() 返回值的辅助函数
|
||||
|
||||
Args:
|
||||
items: scalars().all() 返回的列表
|
||||
scalar: scalar() 返回的标量值
|
||||
one: one() 返回的行对象
|
||||
"""
|
||||
result = MagicMock()
|
||||
result.scalars.return_value.all.return_value = items or []
|
||||
result.scalars.return_value.first.return_value = items[0] if items else None
|
||||
result.scalar_one_or_none.return_value = scalar
|
||||
result.scalar.return_value = scalar if scalar is not None else 0
|
||||
if one:
|
||||
result.one.return_value = one
|
||||
return result
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_token() -> str:
|
||||
"""
|
||||
生成测试用 JWT Token(不依赖数据库)
|
||||
"""
|
||||
from app.core.security import create_access_token
|
||||
return create_access_token(data={"sub": "1", "email": "test@example.com"})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers(auth_token: str) -> dict:
|
||||
"""
|
||||
返回认证请求头
|
||||
|
||||
Returns:
|
||||
包含 Authorization 和 X-Company-ID 的请求头
|
||||
"""
|
||||
return {
|
||||
"Authorization": f"Bearer {auth_token}",
|
||||
"X-Company-ID": "1",
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
"""
|
||||
认证 API 集成测试
|
||||
|
||||
测试注册、登录、获取当前用户、刷新 Token 等端点
|
||||
使用同步 TestClient + Mock 数据库会话
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.security import hash_password
|
||||
from app.models.user import User, UserStatus
|
||||
from tests.conftest import make_db_result
|
||||
|
||||
|
||||
class TestAuthAPI:
|
||||
"""认证 API 集成测试"""
|
||||
|
||||
def test_register_user_success(self, client: TestClient, mock_db: AsyncMock):
|
||||
"""测试用户注册成功"""
|
||||
# mock: 邮箱不存在
|
||||
mock_db.execute = AsyncMock(
|
||||
return_value=make_db_result(scalar=None)
|
||||
)
|
||||
# mock: commit + refresh 后返回带 id 的 user
|
||||
def refresh_side_effect(obj, *args, **kwargs):
|
||||
obj.id = 1
|
||||
obj.created_at = datetime.utcnow()
|
||||
obj.updated_at = datetime.utcnow()
|
||||
obj.last_login_at = None
|
||||
obj.permissions = None
|
||||
obj.status = UserStatus.ACTIVE.value
|
||||
mock_db.refresh.side_effect = refresh_side_effect
|
||||
|
||||
response = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"email": "newuser@example.com",
|
||||
"password": "password123",
|
||||
"full_name": "新用户",
|
||||
"company_id": 1,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["email"] == "newuser@example.com"
|
||||
assert data["full_name"] == "新用户"
|
||||
|
||||
def test_register_duplicate_email(self, client: TestClient, mock_db: AsyncMock):
|
||||
"""测试重复邮箱注册失败"""
|
||||
existing_user = MagicMock()
|
||||
mock_db.execute = AsyncMock(
|
||||
return_value=make_db_result(scalar=existing_user)
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"email": "dup@example.com",
|
||||
"password": "password123",
|
||||
"full_name": "用户1",
|
||||
"company_id": 1,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_login_success(self, client: TestClient, mock_db: AsyncMock):
|
||||
"""测试登录成功"""
|
||||
password = "pass123456"
|
||||
user = User(
|
||||
id=1,
|
||||
company_id=1,
|
||||
email="login@example.com",
|
||||
hashed_password=hash_password(password),
|
||||
full_name="登录用户",
|
||||
role="会计",
|
||||
status=UserStatus.ACTIVE.value,
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
mock_db.execute = AsyncMock(
|
||||
return_value=make_db_result(scalar=user)
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": "login@example.com", "password": password},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "access_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
assert data["user"]["email"] == "login@example.com"
|
||||
|
||||
def test_login_wrong_password(self, client: TestClient, mock_db: AsyncMock):
|
||||
"""测试密码错误登录失败"""
|
||||
user = User(
|
||||
id=1,
|
||||
company_id=1,
|
||||
email="wrong@example.com",
|
||||
hashed_password=hash_password("correctpass"),
|
||||
full_name="用户",
|
||||
status=UserStatus.ACTIVE.value,
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
mock_db.execute = AsyncMock(
|
||||
return_value=make_db_result(scalar=user)
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": "wrong@example.com", "password": "wrongpass"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_login_nonexistent_user(self, client: TestClient, mock_db: AsyncMock):
|
||||
"""测试不存在的用户登录失败"""
|
||||
mock_db.execute = AsyncMock(
|
||||
return_value=make_db_result(scalar=None)
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": "nobody@example.com", "password": "anypass"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_get_current_user_no_token(self, client: TestClient):
|
||||
"""测试无 Token 访问被拒"""
|
||||
response = client.get("/api/auth/me")
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_refresh_token(self, client: TestClient, auth_headers: dict, mock_db: AsyncMock):
|
||||
"""测试刷新 Token"""
|
||||
user = User(
|
||||
id=1,
|
||||
company_id=1,
|
||||
email="test@example.com",
|
||||
hashed_password="x",
|
||||
full_name="测试用户",
|
||||
status=UserStatus.ACTIVE.value,
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
mock_db.execute = AsyncMock(
|
||||
return_value=make_db_result(scalar=user)
|
||||
)
|
||||
|
||||
response = client.post("/api/auth/refresh", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "access_token" in data
|
||||
|
||||
def test_logout(self, client: TestClient, auth_headers: dict, mock_db: AsyncMock):
|
||||
"""测试登出"""
|
||||
user = User(
|
||||
id=1,
|
||||
company_id=1,
|
||||
email="test@example.com",
|
||||
hashed_password="x",
|
||||
full_name="测试用户",
|
||||
status=UserStatus.ACTIVE.value,
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
mock_db.execute = AsyncMock(
|
||||
return_value=make_db_result(scalar=user)
|
||||
)
|
||||
|
||||
response = client.post("/api/auth/logout", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
成本分析计算服务单元测试
|
||||
|
||||
测试 CostCalculatorService 和 CostSummary / DepartmentCost 的核心逻辑
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from app.services.analysis.cost_calculator import (
|
||||
CostCalculatorService,
|
||||
CostSummary,
|
||||
DepartmentCost,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db():
|
||||
"""模拟数据库会话"""
|
||||
return AsyncMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cost_calculator(mock_db):
|
||||
"""创建成本计算服务实例"""
|
||||
return CostCalculatorService(mock_db)
|
||||
|
||||
|
||||
class TestCostSummary:
|
||||
"""成本汇总结果测试"""
|
||||
|
||||
def test_init_defaults(self):
|
||||
"""测试默认值"""
|
||||
summary = CostSummary()
|
||||
assert summary.total_cost == 0.0
|
||||
assert summary.salary_cost == 0.0
|
||||
assert summary.social_security_cost == 0.0
|
||||
assert summary.fund_cost == 0.0
|
||||
assert summary.employee_count == 0
|
||||
|
||||
def test_init_with_values(self):
|
||||
"""测试带值初始化"""
|
||||
summary = CostSummary(
|
||||
total_cost=50000,
|
||||
salary_cost=30000,
|
||||
social_security_cost=10000,
|
||||
fund_cost=10000,
|
||||
employee_count=10,
|
||||
)
|
||||
assert summary.total_cost == 50000
|
||||
assert summary.salary_cost == 30000
|
||||
assert summary.employee_count == 10
|
||||
|
||||
def test_to_dict(self):
|
||||
"""测试转字典"""
|
||||
summary = CostSummary(total_cost=10000, salary_cost=8000, employee_count=5)
|
||||
d = summary.to_dict()
|
||||
assert d["total_cost"] == 10000
|
||||
assert d["salary_cost"] == 8000
|
||||
assert d["employee_count"] == 5
|
||||
|
||||
|
||||
class TestDepartmentCost:
|
||||
"""部门成本测试"""
|
||||
|
||||
def test_init(self):
|
||||
"""测试初始化"""
|
||||
dept = DepartmentCost(department="技术部", employee_count=10, salary_cost=100000)
|
||||
assert dept.department == "技术部"
|
||||
assert dept.employee_count == 10
|
||||
assert dept.salary_cost == 100000
|
||||
|
||||
def test_total_cost_property(self):
|
||||
"""测试 total_cost 属性计算"""
|
||||
dept = DepartmentCost(
|
||||
department="财务部",
|
||||
salary_cost=10000,
|
||||
social_security_cost=3000,
|
||||
fund_cost=1200,
|
||||
)
|
||||
assert dept.total_cost == 14200
|
||||
|
||||
def test_to_dict(self):
|
||||
"""测试转字典"""
|
||||
dept = DepartmentCost(department="技术部", salary_cost=10000, employee_count=5)
|
||||
d = dept.to_dict()
|
||||
assert d["department"] == "技术部"
|
||||
assert d["total_cost"] == 10000
|
||||
assert d["employee_count"] == 5
|
||||
|
||||
|
||||
class TestCostCalculatorService:
|
||||
"""成本计算服务测试"""
|
||||
|
||||
def test_init(self, mock_db):
|
||||
"""测试初始化"""
|
||||
service = CostCalculatorService(mock_db)
|
||||
assert service.db == mock_db
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_calculate_total_cost(self, cost_calculator):
|
||||
"""测试计算总成本"""
|
||||
mock_data = [
|
||||
{"应发工资": 10000, "养老保险(公司)": 2000, "医疗保险(公司)": 1000, "失业保险(公司)": 500, "公积金(公司)": 1200},
|
||||
{"应发工资": 8000, "养老保险(公司)": 1600, "医疗保险(公司)": 800, "失业保险(公司)": 400, "公积金(公司)": 960},
|
||||
]
|
||||
with patch.object(
|
||||
cost_calculator, "_load_cleaned_data", return_value=mock_data
|
||||
):
|
||||
summary = await cost_calculator.calculate_total_cost(task_id=1)
|
||||
|
||||
assert summary.employee_count == 2
|
||||
assert summary.salary_cost == 18000
|
||||
assert summary.social_security_cost == 6300 # (2000+1000+500) + (1600+800+400)
|
||||
assert summary.fund_cost == 2160 # 1200 + 960
|
||||
assert summary.total_cost == 18000 + 6300 + 2160
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_calculate_total_cost_empty(self, cost_calculator):
|
||||
"""测试空数据计算"""
|
||||
with patch.object(
|
||||
cost_calculator, "_load_cleaned_data", return_value=[]
|
||||
):
|
||||
summary = await cost_calculator.calculate_total_cost(task_id=1)
|
||||
|
||||
assert summary.employee_count == 0
|
||||
assert summary.total_cost == 0.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_calculate_by_department(self, cost_calculator):
|
||||
"""测试按部门汇总"""
|
||||
mock_data = [
|
||||
{"部门": "技术部", "应发工资": 10000, "养老保险(公司)": 2000, "公积金(公司)": 1200},
|
||||
{"部门": "技术部", "应发工资": 8000, "养老保险(公司)": 1600, "公积金(公司)": 960},
|
||||
{"部门": "财务部", "应发工资": 12000, "养老保险(公司)": 2400, "公积金(公司)": 1440},
|
||||
]
|
||||
with patch.object(
|
||||
cost_calculator, "_load_cleaned_data", return_value=mock_data
|
||||
):
|
||||
departments = await cost_calculator.calculate_by_department(task_id=1)
|
||||
|
||||
assert len(departments) == 2
|
||||
tech = [d for d in departments if d.department == "技术部"][0]
|
||||
assert tech.employee_count == 2
|
||||
assert tech.salary_cost == 18000
|
||||
finance = [d for d in departments if d.department == "财务部"][0]
|
||||
assert finance.employee_count == 1
|
||||
assert finance.salary_cost == 12000
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
对账任务 API 集成测试
|
||||
|
||||
测试任务列表、统计、详情等端点
|
||||
使用同步 TestClient + Mock 数据库会话
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.models.reconciliation_task import ReconciliationTask
|
||||
from tests.conftest import make_db_result
|
||||
|
||||
|
||||
def _make_task(task_id=1, period="2026-07", status="COMPLETED",
|
||||
total=100, matched=95, exceptions=5):
|
||||
"""构造测试任务对象"""
|
||||
return ReconciliationTask(
|
||||
id=task_id,
|
||||
company_id=1,
|
||||
period=period,
|
||||
status=status,
|
||||
total_employees=total,
|
||||
matched_count=matched,
|
||||
exception_count=exceptions,
|
||||
file_ids=[],
|
||||
reconciliation_result={},
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
|
||||
|
||||
class TestTasksAPI:
|
||||
"""对账任务 API 集成测试"""
|
||||
|
||||
def test_list_tasks_empty(self, client: TestClient, auth_headers: dict, mock_db: AsyncMock):
|
||||
"""测试空任务列表"""
|
||||
mock_db.execute = AsyncMock(return_value=make_db_result(items=[], scalar=0))
|
||||
|
||||
response = client.get("/api/tasks/", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["items"] == []
|
||||
assert data["total"] == 0
|
||||
|
||||
def test_list_tasks_with_data(self, client: TestClient, auth_headers: dict, mock_db: AsyncMock):
|
||||
"""测试有数据的任务列表"""
|
||||
task = _make_task()
|
||||
# tasks.py 先执行 count 查询,再执行 list 查询
|
||||
call_count = [0]
|
||||
async def side_effect(*args, **kwargs):
|
||||
call_count[0] += 1
|
||||
if call_count[0] == 1:
|
||||
return make_db_result(scalar=1) # count
|
||||
return make_db_result(items=[task]) # list
|
||||
mock_db.execute = AsyncMock(side_effect=side_effect)
|
||||
|
||||
response = client.get("/api/tasks/", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["period"] == "2026-07"
|
||||
assert data["items"][0]["status"] == "COMPLETED"
|
||||
|
||||
def test_list_tasks_pagination(self, client: TestClient, auth_headers: dict, mock_db: AsyncMock):
|
||||
"""测试分页"""
|
||||
tasks = [_make_task(task_id=i, period=f"2025-{i:02d}") for i in range(1, 6)]
|
||||
call_count = [0]
|
||||
async def side_effect(*args, **kwargs):
|
||||
call_count[0] += 1
|
||||
if call_count[0] == 1:
|
||||
return make_db_result(scalar=15)
|
||||
return make_db_result(items=tasks)
|
||||
mock_db.execute = AsyncMock(side_effect=side_effect)
|
||||
|
||||
response = client.get("/api/tasks/?page=1&page_size=5", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 5
|
||||
assert data["total"] == 15
|
||||
assert data["page"] == 1
|
||||
|
||||
def test_get_task_detail(self, client: TestClient, auth_headers: dict, mock_db: AsyncMock):
|
||||
"""测试获取任务详情"""
|
||||
task = _make_task(task_id=42)
|
||||
mock_db.get = AsyncMock(return_value=task)
|
||||
|
||||
response = client.get("/api/tasks/42", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == 42
|
||||
assert data["period"] == "2026-07"
|
||||
|
||||
def test_get_task_not_found(self, client: TestClient, auth_headers: dict, mock_db: AsyncMock):
|
||||
"""测试获取不存在的任务"""
|
||||
mock_db.get = AsyncMock(return_value=None)
|
||||
|
||||
response = client.get("/api/tasks/99999", headers=auth_headers)
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_list_tasks_no_company_header(self, client: TestClient, auth_headers: dict, mock_db: AsyncMock):
|
||||
"""测试无企业 ID 头的任务列表(company_id=None 时查不到数据)"""
|
||||
mock_db.execute = AsyncMock(return_value=make_db_result(items=[], scalar=0))
|
||||
|
||||
response = client.get("/api/tasks/")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] == 0
|
||||
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
凭证生成引擎单元测试
|
||||
|
||||
测试 VoucherGeneratorService 的核心逻辑
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from app.services.voucher.generator import VoucherGeneratorService, VoucherEntry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db():
|
||||
return AsyncMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def generator(mock_db):
|
||||
return VoucherGeneratorService(mock_db)
|
||||
|
||||
|
||||
class TestVoucherEntry:
|
||||
"""凭证分录测试"""
|
||||
|
||||
def test_entry_creation(self):
|
||||
"""测试创建借方分录"""
|
||||
entry = VoucherEntry(
|
||||
account_code="6601.01",
|
||||
account_name="管理费用-工资",
|
||||
debit_amount=10000,
|
||||
summary="基本工资",
|
||||
)
|
||||
assert entry.account_code == "6601.01"
|
||||
assert entry.debit_amount == 10000
|
||||
assert entry.credit_amount == 0
|
||||
|
||||
def test_entry_to_dict(self):
|
||||
"""测试分录转字典"""
|
||||
entry = VoucherEntry(
|
||||
account_code="2211.01",
|
||||
account_name="应付职工薪酬",
|
||||
credit_amount=10000,
|
||||
summary="基本工资",
|
||||
department="技术部",
|
||||
)
|
||||
d = entry.to_dict()
|
||||
assert d["account_code"] == "2211.01"
|
||||
assert d["credit_amount"] == 10000
|
||||
assert d["department"] == "技术部"
|
||||
|
||||
|
||||
class TestVoucherGeneratorService:
|
||||
"""凭证生成引擎测试"""
|
||||
|
||||
def test_aggregate_by_field(self, generator):
|
||||
"""测试按字段汇总"""
|
||||
records = [
|
||||
{"基本工资": 10000, "奖金": 2000, "养老保险(公司)": 2000},
|
||||
{"基本工资": 8000, "奖金": 1000, "养老保险(公司)": 1600},
|
||||
]
|
||||
totals = generator._aggregate_by_field(records)
|
||||
assert totals["基本工资"] == 18000
|
||||
assert totals["奖金"] == 3000
|
||||
assert totals["养老保险(公司)"] == 3600
|
||||
|
||||
def test_aggregate_empty(self, generator):
|
||||
"""测试空数据汇总"""
|
||||
totals = generator._aggregate_by_field([])
|
||||
assert totals == {}
|
||||
|
||||
def test_generate_entries_basic(self, generator):
|
||||
"""测试基本分录生成"""
|
||||
field_totals = {"基本工资": 18000}
|
||||
mappings = {
|
||||
"基本工资": {
|
||||
"debit_account": "6601.01",
|
||||
"debit_account_name": "管理费用-工资",
|
||||
"credit_account": "2211.01",
|
||||
"credit_account_name": "应付职工薪酬-工资",
|
||||
}
|
||||
}
|
||||
entries = generator._generate_entries(field_totals, mappings)
|
||||
assert len(entries) >= 2
|
||||
total_debit = sum(e.debit_amount for e in entries)
|
||||
total_credit = sum(e.credit_amount for e in entries)
|
||||
assert total_debit == 18000
|
||||
assert total_credit == 18000
|
||||
|
||||
def test_generate_entries_skip_missing_mapping(self, generator):
|
||||
"""测试跳过无映射的字段"""
|
||||
field_totals = {"基本工资": 18000, "未知字段": 5000}
|
||||
mappings = {
|
||||
"基本工资": {
|
||||
"debit_account": "6601.01",
|
||||
"debit_account_name": "管理费用-工资",
|
||||
"credit_account": "2211.01",
|
||||
"credit_account_name": "应付职工薪酬-工资",
|
||||
}
|
||||
}
|
||||
entries = generator._generate_entries(field_totals, mappings)
|
||||
total_debit = sum(e.debit_amount for e in entries)
|
||||
assert total_debit == 18000
|
||||
|
||||
def test_merge_entries_same_account(self, generator):
|
||||
"""测试合并相同科目"""
|
||||
entries = [
|
||||
VoucherEntry("6601.01", "管理费用-工资", debit_amount=10000, summary="基本工资"),
|
||||
VoucherEntry("6601.01", "管理费用-工资", debit_amount=8000, summary="奖金"),
|
||||
]
|
||||
merged = generator._merge_entries(entries)
|
||||
assert len(merged) == 1
|
||||
assert merged[0].debit_amount == 18000
|
||||
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
凭证模板服务单元测试
|
||||
|
||||
测试 VoucherTemplate 的默认科目映射
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.voucher.template import VoucherTemplate, DEFAULT_ACCOUNT_TEMPLATES
|
||||
|
||||
|
||||
class TestVoucherTemplate:
|
||||
"""凭证模板服务测试"""
|
||||
|
||||
def test_get_default_template_existing(self):
|
||||
"""测试获取已存在的字段模板"""
|
||||
template = VoucherTemplate.get_default_template("基本工资")
|
||||
assert template is not None
|
||||
assert template["debit_account"] == "6601.01"
|
||||
assert template["credit_account"] == "2211.01"
|
||||
assert "管理费用" in template["debit_account_name"]
|
||||
assert "应付职工薪酬" in template["credit_account_name"]
|
||||
|
||||
def test_get_default_template_nonexistent(self):
|
||||
"""测试获取不存在的字段模板"""
|
||||
template = VoucherTemplate.get_default_template("不存在的字段")
|
||||
assert template == {}
|
||||
|
||||
def test_get_all_templates(self):
|
||||
"""测试获取所有模板"""
|
||||
templates = VoucherTemplate.get_all_templates()
|
||||
assert len(templates) > 0
|
||||
assert "基本工资" in templates
|
||||
assert "养老保险(公司)" in templates
|
||||
assert "公积金(公司)" in templates
|
||||
assert "实发工资" in templates
|
||||
|
||||
def test_get_template_fields(self):
|
||||
"""测试获取模板字段列表"""
|
||||
fields = VoucherTemplate.get_template_fields()
|
||||
assert len(fields) > 0
|
||||
assert "基本工资" in fields
|
||||
assert isinstance(fields, list)
|
||||
|
||||
def test_template_structure(self):
|
||||
"""测试模板结构完整性"""
|
||||
for field, template in DEFAULT_ACCOUNT_TEMPLATES.items():
|
||||
assert "debit_account" in template, f"字段 {field} 缺少 debit_account"
|
||||
assert "debit_account_name" in template, f"字段 {field} 缺少 debit_account_name"
|
||||
assert "credit_account" in template, f"字段 {field} 缺少 credit_account"
|
||||
assert "credit_account_name" in template, f"字段 {field} 缺少 credit_account_name"
|
||||
|
||||
def test_social_security_templates(self):
|
||||
"""测试社保相关模板"""
|
||||
for field in ["养老保险(公司)", "医疗保险(公司)", "失业保险(公司)"]:
|
||||
template = VoucherTemplate.get_default_template(field)
|
||||
assert template != {}
|
||||
assert "社保" in template["debit_account_name"]
|
||||
|
||||
def test_fund_template(self):
|
||||
"""测试公积金模板"""
|
||||
template = VoucherTemplate.get_default_template("公积金(公司)")
|
||||
assert template != {}
|
||||
assert "公积金" in template["debit_account_name"]
|
||||
assert "公积金" in template["credit_account_name"]
|
||||
@@ -0,0 +1,178 @@
|
||||
"""
|
||||
凭证 API 集成测试
|
||||
|
||||
测试科目映射 CRUD、凭证模板等端点
|
||||
使用同步 TestClient + Mock 数据库会话
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.models.account_mapping import AccountMapping
|
||||
from tests.conftest import make_db_result
|
||||
|
||||
|
||||
def _make_mapping(mapping_id=1, field="基本工资", debit="6601.01", credit="2211.01"):
|
||||
"""构造测试科目映射对象"""
|
||||
return AccountMapping(
|
||||
id=mapping_id,
|
||||
company_id=1,
|
||||
standard_field=field,
|
||||
debit_account=debit,
|
||||
debit_account_name=f"管理费用-{field}",
|
||||
credit_account=credit,
|
||||
credit_account_name="应付职工薪酬",
|
||||
cost_center=None,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
|
||||
class TestVoucherTemplatesAPI:
|
||||
"""凭证模板 API 测试"""
|
||||
|
||||
def test_get_templates(self, client: TestClient, auth_headers: dict):
|
||||
"""测试获取默认凭证模板"""
|
||||
response = client.get("/api/vouchers/templates/list", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "基本工资" in data
|
||||
assert "养老保险(公司)" in data
|
||||
|
||||
def test_get_templates_no_auth(self, client: TestClient):
|
||||
"""测试无认证访问模板(该端点不需要认证)"""
|
||||
response = client.get("/api/vouchers/templates/list")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestAccountMappingsAPI:
|
||||
"""科目映射 CRUD API 测试"""
|
||||
|
||||
def test_list_mappings_empty(self, client: TestClient, auth_headers: dict, mock_db: AsyncMock):
|
||||
"""测试空映射列表"""
|
||||
mock_db.execute = AsyncMock(return_value=make_db_result(items=[]))
|
||||
|
||||
response = client.get(
|
||||
"/api/vouchers/account-mappings/list", headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
|
||||
def test_create_mapping(self, client: TestClient, auth_headers: dict, mock_db: AsyncMock):
|
||||
"""测试创建科目映射"""
|
||||
def refresh_side_effect(obj, *args, **kwargs):
|
||||
obj.id = 1
|
||||
obj.is_active = True
|
||||
mock_db.refresh.side_effect = refresh_side_effect
|
||||
|
||||
response = client.post(
|
||||
"/api/vouchers/account-mappings",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"standard_field": "基本工资",
|
||||
"debit_account": "6601.01",
|
||||
"debit_account_name": "管理费用-工资",
|
||||
"credit_account": "2211.01",
|
||||
"credit_account_name": "应付职工薪酬-工资",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["standard_field"] == "基本工资"
|
||||
assert data["debit_account"] == "6601.01"
|
||||
assert data["is_active"] is True
|
||||
|
||||
def test_create_and_list_mappings(self, client: TestClient, auth_headers: dict, mock_db: AsyncMock):
|
||||
"""测试创建后查询映射列表"""
|
||||
mappings = [_make_mapping(1, "基本工资"), _make_mapping(2, "奖金", "6601.02")]
|
||||
mock_db.execute = AsyncMock(return_value=make_db_result(items=mappings))
|
||||
|
||||
response = client.get(
|
||||
"/api/vouchers/account-mappings/list", headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data) == 2
|
||||
|
||||
def test_update_mapping(self, client: TestClient, auth_headers: dict, mock_db: AsyncMock):
|
||||
"""测试更新科目映射"""
|
||||
existing = _make_mapping(1)
|
||||
mock_db.get = AsyncMock(return_value=existing)
|
||||
|
||||
def refresh_side_effect(obj, *args, **kwargs):
|
||||
pass
|
||||
mock_db.refresh.side_effect = refresh_side_effect
|
||||
|
||||
response = client.put(
|
||||
"/api/vouchers/account-mappings/1",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"standard_field": "基本工资",
|
||||
"debit_account": "6601.03",
|
||||
"debit_account_name": "销售费用-工资",
|
||||
"credit_account": "2211.01",
|
||||
"credit_account_name": "应付职工薪酬-工资",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["debit_account"] == "6601.03"
|
||||
|
||||
def test_update_mapping_not_found(self, client: TestClient, auth_headers: dict, mock_db: AsyncMock):
|
||||
"""测试更新不存在的映射"""
|
||||
mock_db.get = AsyncMock(return_value=None)
|
||||
|
||||
response = client.put(
|
||||
"/api/vouchers/account-mappings/99999",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"standard_field": "基本工资",
|
||||
"debit_account": "6601.01",
|
||||
"debit_account_name": "管理费用-工资",
|
||||
"credit_account": "2211.01",
|
||||
"credit_account_name": "应付职工薪酬-工资",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_delete_mapping(self, client: TestClient, auth_headers: dict, mock_db: AsyncMock):
|
||||
"""测试删除科目映射"""
|
||||
existing = _make_mapping(1, "奖金")
|
||||
mock_db.get = AsyncMock(return_value=existing)
|
||||
|
||||
response = client.delete(
|
||||
"/api/vouchers/account-mappings/1", headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_delete_mapping_not_found(self, client: TestClient, auth_headers: dict, mock_db: AsyncMock):
|
||||
"""测试删除不存在的映射"""
|
||||
mock_db.get = AsyncMock(return_value=None)
|
||||
|
||||
response = client.delete(
|
||||
"/api/vouchers/account-mappings/99999", headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_create_mapping_with_cost_center(self, client: TestClient, auth_headers: dict, mock_db: AsyncMock):
|
||||
"""测试带成本中心创建映射"""
|
||||
def refresh_side_effect(obj, *args, **kwargs):
|
||||
obj.id = 1
|
||||
obj.is_active = True
|
||||
mock_db.refresh.side_effect = refresh_side_effect
|
||||
|
||||
response = client.post(
|
||||
"/api/vouchers/account-mappings",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"standard_field": "基本工资",
|
||||
"debit_account": "6601.01",
|
||||
"debit_account_name": "管理费用-工资",
|
||||
"credit_account": "2211.01",
|
||||
"credit_account_name": "应付职工薪酬-工资",
|
||||
"cost_center": "技术部",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["cost_center"] == "技术部"
|
||||
Reference in New Issue
Block a user