forked from erp-dev/erp
feat: completed
This commit is contained in:
416
api_v2/test_cost_api.py
Normal file
416
api_v2/test_cost_api.py
Normal file
@@ -0,0 +1,416 @@
|
||||
"""Cost 模块 API 测试"""
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from basic_info import models as basic_models
|
||||
from cost import models as cost_models
|
||||
|
||||
|
||||
class CostAPITest(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.merchant = basic_models.Merchant.objects.create(
|
||||
name='测试商户', type=basic_models.MerchantTypeEnum.STORE,
|
||||
)
|
||||
self.other_merchant = basic_models.Merchant.objects.create(
|
||||
name='其他商户', type=basic_models.MerchantTypeEnum.STORE,
|
||||
)
|
||||
self.user = get_user_model().objects.create_user(username='cost-api-user', password='pass12345')
|
||||
self.other_user = get_user_model().objects.create_user(username='cost-api-other', password='pass12345')
|
||||
self.employee = basic_models.Employee.objects.create(
|
||||
merchant=self.merchant, sys_user=self.user, name='成本员工',
|
||||
)
|
||||
self.other_employee = basic_models.Employee.objects.create(
|
||||
merchant=self.other_merchant, sys_user=self.other_user, name='其他员工',
|
||||
)
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
# ==================== Category API Tests ====================
|
||||
|
||||
def test_list_categories(self):
|
||||
cost_models.CostCategory.objects.create(
|
||||
merchant=self.merchant, unique_key='electricity', name='电费',
|
||||
)
|
||||
cost_models.CostCategory.objects.create(
|
||||
merchant=self.merchant, unique_key='water', name='水费',
|
||||
)
|
||||
resp = self.client.get('/api/v2/cost-categories/')
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(len(resp.data), 2)
|
||||
self.assertEqual(resp.data[0]['name'], '水费') # name 排序
|
||||
|
||||
def test_list_categories_filter_by_merchant_id(self):
|
||||
cost_models.CostCategory.objects.create(
|
||||
merchant=self.merchant, unique_key='elec', name='电费',
|
||||
)
|
||||
cost_models.CostCategory.objects.create(
|
||||
merchant=self.other_merchant, unique_key='water', name='水费',
|
||||
)
|
||||
resp = self.client.get(f'/api/v2/cost-categories/?merchant_id={self.merchant.id}')
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(len(resp.data), 1)
|
||||
self.assertEqual(resp.data[0]['name'], '电费')
|
||||
|
||||
def test_create_category(self):
|
||||
resp = self.client.post('/api/v2/cost-categories/', {
|
||||
'unique_key': 'electricity',
|
||||
'name': '电费',
|
||||
'description': '每月电费',
|
||||
}, format='json')
|
||||
self.assertEqual(resp.status_code, 201)
|
||||
self.assertEqual(resp.data['unique_key'], 'electricity')
|
||||
self.assertEqual(resp.data['name'], '电费')
|
||||
self.assertEqual(resp.data['merchant_id'], self.merchant.id)
|
||||
|
||||
def test_create_category_missing_required_field(self):
|
||||
resp = self.client.post('/api/v2/cost-categories/', {
|
||||
'name': '电费',
|
||||
}, format='json')
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
|
||||
def test_get_category_detail(self):
|
||||
cat = cost_models.CostCategory.objects.create(
|
||||
merchant=self.merchant, unique_key='elec', name='电费',
|
||||
)
|
||||
resp = self.client.get(f'/api/v2/cost-categories/{cat.id}/')
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(resp.data['name'], '电费')
|
||||
|
||||
def test_get_category_detail_404(self):
|
||||
resp = self.client.get('/api/v2/cost-categories/99999/')
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
|
||||
def test_get_category_detail_cross_merchant_404(self):
|
||||
"""其他商户的类目返回 404"""
|
||||
cat = cost_models.CostCategory.objects.create(
|
||||
merchant=self.other_merchant, unique_key='elec', name='电费',
|
||||
)
|
||||
resp = self.client.get(f'/api/v2/cost-categories/{cat.id}/')
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
|
||||
def test_update_category(self):
|
||||
cat = cost_models.CostCategory.objects.create(
|
||||
merchant=self.merchant, unique_key='elec', name='电费',
|
||||
)
|
||||
resp = self.client.put(f'/api/v2/cost-categories/{cat.id}/', {
|
||||
'name': '电力费',
|
||||
'description': '已更新',
|
||||
}, format='json')
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(resp.data['name'], '电力费')
|
||||
self.assertEqual(resp.data['description'], '已更新')
|
||||
|
||||
def test_update_category_404(self):
|
||||
resp = self.client.put('/api/v2/cost-categories/99999/', {
|
||||
'name': 'xxx',
|
||||
}, format='json')
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
|
||||
def test_delete_category(self):
|
||||
cat = cost_models.CostCategory.objects.create(
|
||||
merchant=self.merchant, unique_key='elec', name='电费',
|
||||
)
|
||||
resp = self.client.delete(f'/api/v2/cost-categories/{cat.id}/')
|
||||
self.assertEqual(resp.status_code, 204)
|
||||
self.assertFalse(cost_models.CostCategory.objects.filter(id=cat.id).exists())
|
||||
|
||||
def test_delete_category_404(self):
|
||||
resp = self.client.delete('/api/v2/cost-categories/99999/')
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
|
||||
# ==================== Entry API Tests ====================
|
||||
|
||||
def _create_category(self, key='elec', name='电费'):
|
||||
return cost_models.CostCategory.objects.create(
|
||||
merchant=self.merchant, unique_key=key, name=name,
|
||||
)
|
||||
|
||||
def test_list_entries(self):
|
||||
cat = self._create_category()
|
||||
cost_models.CostEntry.objects.create(
|
||||
merchant=self.merchant, category=cat,
|
||||
amount=Decimal('100.00'), occurred_at=date(2026, 6, 1), operator=self.employee,
|
||||
)
|
||||
cost_models.CostEntry.objects.create(
|
||||
merchant=self.merchant, category=cat,
|
||||
amount=Decimal('200.00'), occurred_at=date(2026, 6, 10), operator=self.employee,
|
||||
)
|
||||
resp = self.client.get('/api/v2/cost-entries/')
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(len(resp.data), 2)
|
||||
self.assertEqual(resp.data[0]['amount'], '200.00') # 按 occurred_at 降序
|
||||
|
||||
def test_list_entries_filter_by_category(self):
|
||||
cat1 = self._create_category('elec', '电费')
|
||||
cat2 = self._create_category('water', '水费')
|
||||
cost_models.CostEntry.objects.create(
|
||||
merchant=self.merchant, category=cat1,
|
||||
amount=Decimal('100.00'), occurred_at=date(2026, 6, 1),
|
||||
)
|
||||
cost_models.CostEntry.objects.create(
|
||||
merchant=self.merchant, category=cat2,
|
||||
amount=Decimal('200.00'), occurred_at=date(2026, 6, 10),
|
||||
)
|
||||
resp = self.client.get(f'/api/v2/cost-entries/?category_id={cat1.id}')
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(len(resp.data), 1)
|
||||
self.assertEqual(resp.data[0]['amount'], '100.00')
|
||||
|
||||
def test_list_entries_filter_by_date_range(self):
|
||||
cat = self._create_category()
|
||||
cost_models.CostEntry.objects.create(
|
||||
merchant=self.merchant, category=cat,
|
||||
amount=Decimal('100.00'), occurred_at=date(2026, 6, 1),
|
||||
)
|
||||
cost_models.CostEntry.objects.create(
|
||||
merchant=self.merchant, category=cat,
|
||||
amount=Decimal('200.00'), occurred_at=date(2026, 7, 1),
|
||||
)
|
||||
resp = self.client.get('/api/v2/cost-entries/?start=2026-06-15&end=2026-07-15')
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(len(resp.data), 1)
|
||||
self.assertEqual(resp.data[0]['amount'], '200.00')
|
||||
|
||||
def test_list_entries_filter_by_merchant_id(self):
|
||||
cat = self._create_category()
|
||||
other_cat = cost_models.CostCategory.objects.create(
|
||||
merchant=self.other_merchant, unique_key='other', name='其他',
|
||||
)
|
||||
cost_models.CostEntry.objects.create(
|
||||
merchant=self.merchant, category=cat,
|
||||
amount=Decimal('100.00'), occurred_at=date(2026, 6, 1),
|
||||
)
|
||||
cost_models.CostEntry.objects.create(
|
||||
merchant=self.other_merchant, category=other_cat,
|
||||
amount=Decimal('999.00'), occurred_at=date(2026, 6, 1),
|
||||
)
|
||||
resp = self.client.get(f'/api/v2/cost-entries/?merchant_id={self.merchant.id}')
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(len(resp.data), 1)
|
||||
|
||||
def test_create_entry(self):
|
||||
cat = self._create_category()
|
||||
resp = self.client.post('/api/v2/cost-entries/', {
|
||||
'category_id': cat.id,
|
||||
'amount': '350.00',
|
||||
'occurred_at': '2026-06-01',
|
||||
'remarks': '六月份电费',
|
||||
}, format='json')
|
||||
self.assertEqual(resp.status_code, 201)
|
||||
self.assertEqual(resp.data['amount'], '350.00')
|
||||
self.assertEqual(resp.data['category_id'], cat.id)
|
||||
self.assertEqual(resp.data['category_name'], '电费')
|
||||
self.assertEqual(resp.data['operator_id'], self.employee.id)
|
||||
|
||||
def test_create_entry_category_not_found(self):
|
||||
resp = self.client.post('/api/v2/cost-entries/', {
|
||||
'category_id': 99999,
|
||||
'amount': '100.00',
|
||||
'occurred_at': '2026-06-01',
|
||||
}, format='json')
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
|
||||
def test_create_entry_cross_merchant_category_404(self):
|
||||
"""不能用其他商户的类目创建"""
|
||||
other_cat = cost_models.CostCategory.objects.create(
|
||||
merchant=self.other_merchant, unique_key='other', name='其他',
|
||||
)
|
||||
resp = self.client.post('/api/v2/cost-entries/', {
|
||||
'category_id': other_cat.id,
|
||||
'amount': '100.00',
|
||||
'occurred_at': '2026-06-01',
|
||||
}, format='json')
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
|
||||
def test_create_entry_missing_required_field(self):
|
||||
cat = self._create_category()
|
||||
resp = self.client.post('/api/v2/cost-entries/', {
|
||||
'category_id': cat.id,
|
||||
'amount': '100.00',
|
||||
}, format='json')
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
|
||||
def test_get_entry_detail(self):
|
||||
cat = self._create_category()
|
||||
entry = cost_models.CostEntry.objects.create(
|
||||
merchant=self.merchant, category=cat,
|
||||
amount=Decimal('150.00'), occurred_at=date(2026, 6, 1),
|
||||
operator=self.employee, remarks='test',
|
||||
)
|
||||
resp = self.client.get(f'/api/v2/cost-entries/{entry.id}/')
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(resp.data['amount'], '150.00')
|
||||
self.assertEqual(resp.data['category_name'], '电费')
|
||||
|
||||
def test_get_entry_detail_404(self):
|
||||
resp = self.client.get('/api/v2/cost-entries/99999/')
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
|
||||
def test_get_entry_detail_cross_merchant_404(self):
|
||||
other_cat = cost_models.CostCategory.objects.create(
|
||||
merchant=self.other_merchant, unique_key='other', name='其他',
|
||||
)
|
||||
entry = cost_models.CostEntry.objects.create(
|
||||
merchant=self.other_merchant, category=other_cat,
|
||||
amount=Decimal('100.00'), occurred_at=date(2026, 6, 1),
|
||||
)
|
||||
resp = self.client.get(f'/api/v2/cost-entries/{entry.id}/')
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
|
||||
def test_update_entry(self):
|
||||
cat = self._create_category()
|
||||
entry = cost_models.CostEntry.objects.create(
|
||||
merchant=self.merchant, category=cat,
|
||||
amount=Decimal('150.00'), occurred_at=date(2026, 6, 1),
|
||||
)
|
||||
resp = self.client.put(f'/api/v2/cost-entries/{entry.id}/', {
|
||||
'amount': '999.00',
|
||||
'remarks': '已修改',
|
||||
}, format='json')
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(resp.data['amount'], '999.00')
|
||||
self.assertEqual(resp.data['remarks'], '已修改')
|
||||
|
||||
def test_update_entry_change_category(self):
|
||||
cat1 = self._create_category('elec', '电费')
|
||||
cat2 = self._create_category('water', '水费')
|
||||
entry = cost_models.CostEntry.objects.create(
|
||||
merchant=self.merchant, category=cat1,
|
||||
amount=Decimal('100.00'), occurred_at=date(2026, 6, 1),
|
||||
)
|
||||
resp = self.client.put(f'/api/v2/cost-entries/{entry.id}/', {
|
||||
'category_id': cat2.id,
|
||||
}, format='json')
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(resp.data['category_id'], cat2.id)
|
||||
self.assertEqual(resp.data['category_name'], '水费')
|
||||
|
||||
def test_update_entry_404(self):
|
||||
resp = self.client.put('/api/v2/cost-entries/99999/', {
|
||||
'amount': '100.00',
|
||||
}, format='json')
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
|
||||
def test_delete_entry(self):
|
||||
cat = self._create_category()
|
||||
entry = cost_models.CostEntry.objects.create(
|
||||
merchant=self.merchant, category=cat,
|
||||
amount=Decimal('100.00'), occurred_at=date(2026, 6, 1),
|
||||
)
|
||||
resp = self.client.delete(f'/api/v2/cost-entries/{entry.id}/')
|
||||
self.assertEqual(resp.status_code, 204)
|
||||
self.assertFalse(cost_models.CostEntry.objects.filter(id=entry.id).exists())
|
||||
|
||||
def test_delete_entry_404(self):
|
||||
resp = self.client.delete('/api/v2/cost-entries/99999/')
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
|
||||
# ==================== Summary API Tests ====================
|
||||
|
||||
def test_summary_by_category(self):
|
||||
cat1 = self._create_category('elec', '电费')
|
||||
cat2 = self._create_category('water', '水费')
|
||||
cost_models.CostEntry.objects.create(
|
||||
merchant=self.merchant, category=cat1,
|
||||
amount=Decimal('300.00'), occurred_at=date(2026, 6, 1),
|
||||
)
|
||||
cost_models.CostEntry.objects.create(
|
||||
merchant=self.merchant, category=cat1,
|
||||
amount=Decimal('200.00'), occurred_at=date(2026, 6, 10),
|
||||
)
|
||||
cost_models.CostEntry.objects.create(
|
||||
merchant=self.merchant, category=cat2,
|
||||
amount=Decimal('80.00'), occurred_at=date(2026, 6, 5),
|
||||
)
|
||||
resp = self.client.get('/api/v2/cost-summary/by-category/')
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(len(resp.data['results']), 2)
|
||||
self.assertEqual(resp.data['results'][0]['category_name'], '电费')
|
||||
self.assertEqual(resp.data['results'][0]['total_amount'], '500.00')
|
||||
self.assertEqual(resp.data['results'][0]['entry_count'], 2)
|
||||
self.assertEqual(resp.data['results'][1]['category_name'], '水费')
|
||||
self.assertEqual(resp.data['results'][1]['total_amount'], '80.00')
|
||||
|
||||
def test_summary_by_category_with_date_range(self):
|
||||
cat = self._create_category()
|
||||
cost_models.CostEntry.objects.create(
|
||||
merchant=self.merchant, category=cat,
|
||||
amount=Decimal('100.00'), occurred_at=date(2026, 6, 1),
|
||||
)
|
||||
cost_models.CostEntry.objects.create(
|
||||
merchant=self.merchant, category=cat,
|
||||
amount=Decimal('200.00'), occurred_at=date(2026, 7, 1),
|
||||
)
|
||||
resp = self.client.get('/api/v2/cost-summary/by-category/?start=2026-06-15&end=2026-07-15')
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(resp.data['results'][0]['total_amount'], '200.00')
|
||||
|
||||
def test_summary_by_category_with_merchant_id(self):
|
||||
cat = self._create_category()
|
||||
other_cat = cost_models.CostCategory.objects.create(
|
||||
merchant=self.other_merchant, unique_key='other', name='其他',
|
||||
)
|
||||
cost_models.CostEntry.objects.create(
|
||||
merchant=self.merchant, category=cat,
|
||||
amount=Decimal('100.00'), occurred_at=date(2026, 6, 1),
|
||||
)
|
||||
cost_models.CostEntry.objects.create(
|
||||
merchant=self.other_merchant, category=other_cat,
|
||||
amount=Decimal('9999.00'), occurred_at=date(2026, 6, 1),
|
||||
)
|
||||
resp = self.client.get(f'/api/v2/cost-summary/by-category/?merchant_id={self.merchant.id}')
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(len(resp.data['results']), 1)
|
||||
self.assertEqual(resp.data['results'][0]['total_amount'], '100.00')
|
||||
|
||||
def test_summary_by_category_no_data(self):
|
||||
resp = self.client.get('/api/v2/cost-summary/by-category/')
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(resp.data['results'], [])
|
||||
|
||||
def test_summary_includes_metadata(self):
|
||||
resp = self.client.get('/api/v2/cost-summary/by-category/')
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertIn('merchant_id', resp.data)
|
||||
self.assertIn('start_date', resp.data)
|
||||
self.assertIn('end_date', resp.data)
|
||||
self.assertIn('results', resp.data)
|
||||
|
||||
# ==================== Auth Tests ====================
|
||||
|
||||
def test_unauthenticated_returns_401(self):
|
||||
client = APIClient()
|
||||
resp = client.get('/api/v2/cost-categories/')
|
||||
self.assertEqual(resp.status_code, 401)
|
||||
|
||||
def test_user_without_employee_raises_error(self):
|
||||
"""用户未关联 Employee 时报错"""
|
||||
self.user = get_user_model().objects.create_user(username='noemp', password='pass12345')
|
||||
self.client.force_authenticate(user=self.user)
|
||||
resp = self.client.get('/api/v2/cost-categories/')
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
self.assertIn('员工', str(resp.data))
|
||||
|
||||
def test_cross_merchant_isolation(self):
|
||||
"""商户 A 用户看不到商户 B 的数据"""
|
||||
cat_a = self._create_category('elec', '电费')
|
||||
cost_models.CostEntry.objects.create(
|
||||
merchant=self.merchant, category=cat_a,
|
||||
amount=Decimal('100.00'), occurred_at=date(2026, 6, 1),
|
||||
)
|
||||
cat_b = cost_models.CostCategory.objects.create(
|
||||
merchant=self.other_merchant, unique_key='water', name='水费',
|
||||
)
|
||||
cost_models.CostEntry.objects.create(
|
||||
merchant=self.other_merchant, category=cat_b,
|
||||
amount=Decimal('999.00'), occurred_at=date(2026, 6, 1),
|
||||
)
|
||||
# 当前用户属于 merchant,应该只看到自己的数据
|
||||
resp = self.client.get('/api/v2/cost-entries/')
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(len(resp.data), 1)
|
||||
self.assertEqual(resp.data[0]['amount'], '100.00')
|
||||
@@ -41,6 +41,13 @@ from api_v2.views import (
|
||||
AgentMesProductionAssignmentListView,
|
||||
)
|
||||
from api_v2.views.basic_info import CustomerEmployeeBindingView, MyVisiblePagesView
|
||||
from api_v2.views.cost import (
|
||||
CostCategoryDetailView,
|
||||
CostCategoryListCreateView,
|
||||
CostEntryDetailView,
|
||||
CostEntryListCreateView,
|
||||
CostSummaryByCategoryView,
|
||||
)
|
||||
from api_v2.views.wecom import WecomBindingView, WecomLoginView
|
||||
|
||||
urlpatterns = [
|
||||
@@ -84,6 +91,11 @@ urlpatterns = [
|
||||
path('mes/production-assignments/<int:assignment_id>/', ProductionAssignmentDetailView.as_view(), name='api_v2_mes_production_assignment_detail'),
|
||||
path('shipment-delivery-photos/', ShipmentDeliveryPhotoListCreateView.as_view(), name='api_v2_shipment_delivery_photo_list_create'),
|
||||
path('shipment-delivery-photos/<int:photo_id>/', ShipmentDeliveryPhotoDetailView.as_view(), name='api_v2_shipment_delivery_photo_detail'),
|
||||
path('cost-categories/', CostCategoryListCreateView.as_view(), name='api_v2_cost_category_list_create'),
|
||||
path('cost-categories/<int:category_id>/', CostCategoryDetailView.as_view(), name='api_v2_cost_category_detail'),
|
||||
path('cost-entries/', CostEntryListCreateView.as_view(), name='api_v2_cost_entry_list_create'),
|
||||
path('cost-entries/<int:entry_id>/', CostEntryDetailView.as_view(), name='api_v2_cost_entry_detail'),
|
||||
path('cost-summary/by-category/', CostSummaryByCategoryView.as_view(), name='api_v2_cost_summary_by_category'),
|
||||
path('wecom/binduser/', WecomBindingView.as_view(), name='api_v2_wecom_binduser'),
|
||||
path('wecom/login/', WecomLoginView.as_view(), name='api_v2_wecom_login'),
|
||||
]
|
||||
|
||||
@@ -47,6 +47,13 @@ from .ai import (
|
||||
AgentMesProductionAssignmentListView,
|
||||
)
|
||||
from .shipment_delivery_photo import ShipmentDeliveryPhotoDetailView, ShipmentDeliveryPhotoListCreateView
|
||||
from .cost import (
|
||||
CostCategoryDetailView,
|
||||
CostCategoryListCreateView,
|
||||
CostEntryDetailView,
|
||||
CostEntryListCreateView,
|
||||
CostSummaryByCategoryView,
|
||||
)
|
||||
from .wecom import WecomBindingView, WecomLoginView
|
||||
|
||||
__all__ = [
|
||||
@@ -89,4 +96,9 @@ __all__ = [
|
||||
'AgentMesProductionAssignmentListView',
|
||||
'ShipmentDeliveryPhotoListCreateView',
|
||||
'ShipmentDeliveryPhotoDetailView',
|
||||
'CostCategoryListCreateView',
|
||||
'CostCategoryDetailView',
|
||||
'CostEntryListCreateView',
|
||||
'CostEntryDetailView',
|
||||
'CostSummaryByCategoryView',
|
||||
]
|
||||
|
||||
310
api_v2/views/cost.py
Normal file
310
api_v2/views/cost.py
Normal file
@@ -0,0 +1,310 @@
|
||||
from datetime import date
|
||||
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework import permissions, serializers, status
|
||||
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from basic_info.models import Merchant
|
||||
from cost import models as cost_models
|
||||
from cost import services as cost_services
|
||||
|
||||
|
||||
def _get_employee(request):
|
||||
employee = getattr(request.user, 'employee', None)
|
||||
if employee is None:
|
||||
raise serializers.ValidationError('当前用户未关联员工')
|
||||
return employee
|
||||
|
||||
|
||||
def _category_payload(category: cost_models.CostCategory) -> dict:
|
||||
return {
|
||||
'id': category.id,
|
||||
'merchant_id': category.merchant_id,
|
||||
'unique_key': category.unique_key,
|
||||
'name': category.name,
|
||||
'parent_id': category.parent_id,
|
||||
'description': category.description,
|
||||
'created_at': category.created_at.isoformat().replace('+00:00', 'Z'),
|
||||
'updated_at': category.updated_at.isoformat().replace('+00:00', 'Z'),
|
||||
}
|
||||
|
||||
|
||||
def _entry_payload(entry: cost_models.CostEntry) -> dict:
|
||||
return {
|
||||
'id': entry.id,
|
||||
'merchant_id': entry.merchant_id,
|
||||
'category_id': entry.category_id,
|
||||
'category_name': entry.category.name if entry.category_id else '',
|
||||
'amount': str(entry.amount),
|
||||
'occurred_at': entry.occurred_at.isoformat(),
|
||||
'operator_id': entry.operator_id,
|
||||
'image1': _image_url(entry.image1),
|
||||
'image2': _image_url(entry.image2),
|
||||
'source_module': entry.source_module or '',
|
||||
'source_id': entry.source_id or '',
|
||||
'remarks': entry.remarks or '',
|
||||
'created_at': entry.created_at.isoformat().replace('+00:00', 'Z'),
|
||||
'updated_at': entry.updated_at.isoformat().replace('+00:00', 'Z'),
|
||||
}
|
||||
|
||||
|
||||
def _image_url(image_field) -> str:
|
||||
if not image_field:
|
||||
return ''
|
||||
try:
|
||||
return image_field.url
|
||||
except (ValueError, AttributeError):
|
||||
return ''
|
||||
|
||||
|
||||
# ==================== Category Write Serializers ====================
|
||||
|
||||
|
||||
class CategoryWriteSerializer(serializers.Serializer):
|
||||
unique_key = serializers.CharField(max_length=100)
|
||||
name = serializers.CharField(max_length=100)
|
||||
parent_id = serializers.IntegerField(required=False, allow_null=True)
|
||||
description = serializers.CharField(required=False, allow_blank=True, default='')
|
||||
|
||||
|
||||
class CategoryUpdateSerializer(serializers.Serializer):
|
||||
unique_key = serializers.CharField(required=False, max_length=100)
|
||||
name = serializers.CharField(required=False, max_length=100)
|
||||
parent_id = serializers.IntegerField(required=False, allow_null=True)
|
||||
description = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
|
||||
# ==================== Entry Write Serializers ====================
|
||||
|
||||
|
||||
class EntryWriteSerializer(serializers.Serializer):
|
||||
category_id = serializers.IntegerField(min_value=1)
|
||||
amount = serializers.DecimalField(max_digits=15, decimal_places=2, min_value=0)
|
||||
occurred_at = serializers.DateField()
|
||||
image1 = serializers.ImageField(required=False, allow_null=True)
|
||||
image2 = serializers.ImageField(required=False, allow_null=True)
|
||||
source_module = serializers.CharField(required=False, allow_blank=True, default='')
|
||||
source_id = serializers.CharField(required=False, allow_blank=True, default='')
|
||||
remarks = serializers.CharField(required=False, allow_blank=True, default='')
|
||||
|
||||
|
||||
class EntryUpdateSerializer(serializers.Serializer):
|
||||
category_id = serializers.IntegerField(required=False, min_value=1)
|
||||
amount = serializers.DecimalField(required=False, max_digits=15, decimal_places=2, min_value=0)
|
||||
occurred_at = serializers.DateField(required=False)
|
||||
image1 = serializers.ImageField(required=False, allow_null=True)
|
||||
image2 = serializers.ImageField(required=False, allow_null=True)
|
||||
source_module = serializers.CharField(required=False, allow_blank=True)
|
||||
source_id = serializers.CharField(required=False, allow_blank=True)
|
||||
remarks = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
|
||||
# ==================== Category Views ====================
|
||||
|
||||
|
||||
class CostCategoryListCreateView(APIView):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
employee = _get_employee(request)
|
||||
merchant_id = request.query_params.get('merchant_id')
|
||||
queryset = cost_models.CostCategory.objects.filter(
|
||||
merchant_id=merchant_id if merchant_id else employee.merchant_id,
|
||||
).order_by('name')
|
||||
return Response([_category_payload(c) for c in queryset])
|
||||
|
||||
def post(self, request):
|
||||
employee = _get_employee(request)
|
||||
serializer = CategoryWriteSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
data = serializer.validated_data
|
||||
|
||||
category = cost_models.CostCategory.objects.create(
|
||||
merchant=employee.merchant,
|
||||
unique_key=data['unique_key'],
|
||||
name=data['name'],
|
||||
parent_id=data.get('parent_id'),
|
||||
description=data.get('description', ''),
|
||||
)
|
||||
return Response(_category_payload(category), status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class CostCategoryDetailView(APIView):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get(self, request, category_id):
|
||||
employee = _get_employee(request)
|
||||
category = get_object_or_404(
|
||||
cost_models.CostCategory,
|
||||
id=category_id,
|
||||
merchant_id=employee.merchant_id,
|
||||
)
|
||||
return Response(_category_payload(category))
|
||||
|
||||
def put(self, request, category_id):
|
||||
employee = _get_employee(request)
|
||||
category = get_object_or_404(
|
||||
cost_models.CostCategory,
|
||||
id=category_id,
|
||||
merchant_id=employee.merchant_id,
|
||||
)
|
||||
serializer = CategoryUpdateSerializer(data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
data = serializer.validated_data
|
||||
|
||||
for field in ('unique_key', 'name', 'parent_id', 'description'):
|
||||
if field in data:
|
||||
setattr(category, field, data[field])
|
||||
category.save(update_fields=[k for k in data if k in data])
|
||||
return Response(_category_payload(category))
|
||||
|
||||
def delete(self, request, category_id):
|
||||
employee = _get_employee(request)
|
||||
category = get_object_or_404(
|
||||
cost_models.CostCategory,
|
||||
id=category_id,
|
||||
merchant_id=employee.merchant_id,
|
||||
)
|
||||
category.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
# ==================== Entry Views ====================
|
||||
|
||||
|
||||
class CostEntryListCreateView(APIView):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
parser_classes = [JSONParser, FormParser, MultiPartParser]
|
||||
|
||||
def get(self, request):
|
||||
employee = _get_employee(request)
|
||||
merchant_id = request.query_params.get('merchant_id')
|
||||
category_id = request.query_params.get('category_id')
|
||||
start = request.query_params.get('start')
|
||||
end = request.query_params.get('end')
|
||||
|
||||
queryset = cost_models.CostEntry.objects.filter(
|
||||
merchant_id=merchant_id if merchant_id else employee.merchant_id,
|
||||
).select_related('category').order_by('-occurred_at', '-created_at')
|
||||
|
||||
if category_id:
|
||||
queryset = queryset.filter(category_id=category_id)
|
||||
if start:
|
||||
queryset = queryset.filter(occurred_at__gte=start)
|
||||
if end:
|
||||
queryset = queryset.filter(occurred_at__lte=end)
|
||||
|
||||
return Response([_entry_payload(e) for e in queryset])
|
||||
|
||||
def post(self, request):
|
||||
employee = _get_employee(request)
|
||||
serializer = EntryWriteSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
data = serializer.validated_data
|
||||
|
||||
category = get_object_or_404(
|
||||
cost_models.CostCategory,
|
||||
id=data['category_id'],
|
||||
merchant_id=employee.merchant_id,
|
||||
)
|
||||
|
||||
entry = cost_models.CostEntry.objects.create(
|
||||
merchant=employee.merchant,
|
||||
category=category,
|
||||
amount=data['amount'],
|
||||
occurred_at=data['occurred_at'],
|
||||
operator=employee,
|
||||
image1=data.get('image1'),
|
||||
image2=data.get('image2'),
|
||||
source_module=data.get('source_module', ''),
|
||||
source_id=data.get('source_id', ''),
|
||||
remarks=data.get('remarks', ''),
|
||||
)
|
||||
return Response(_entry_payload(entry), status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class CostEntryDetailView(APIView):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
parser_classes = [JSONParser, FormParser, MultiPartParser]
|
||||
|
||||
def get(self, request, entry_id):
|
||||
employee = _get_employee(request)
|
||||
entry = get_object_or_404(
|
||||
cost_models.CostEntry.objects.select_related('category'),
|
||||
id=entry_id,
|
||||
merchant_id=employee.merchant_id,
|
||||
)
|
||||
return Response(_entry_payload(entry))
|
||||
|
||||
def put(self, request, entry_id):
|
||||
employee = _get_employee(request)
|
||||
entry = get_object_or_404(
|
||||
cost_models.CostEntry,
|
||||
id=entry_id,
|
||||
merchant_id=employee.merchant_id,
|
||||
)
|
||||
serializer = EntryUpdateSerializer(data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
data = serializer.validated_data
|
||||
|
||||
update_fields = []
|
||||
if 'category_id' in data:
|
||||
entry.category = get_object_or_404(
|
||||
cost_models.CostCategory,
|
||||
id=data.pop('category_id'),
|
||||
merchant_id=employee.merchant_id,
|
||||
)
|
||||
update_fields.append('category')
|
||||
for field, value in data.items():
|
||||
setattr(entry, field, value)
|
||||
update_fields.append(field)
|
||||
if update_fields:
|
||||
entry.save(update_fields=update_fields)
|
||||
entry.refresh_from_db()
|
||||
return Response(_entry_payload(entry))
|
||||
|
||||
def delete(self, request, entry_id):
|
||||
employee = _get_employee(request)
|
||||
entry = get_object_or_404(
|
||||
cost_models.CostEntry,
|
||||
id=entry_id,
|
||||
merchant_id=employee.merchant_id,
|
||||
)
|
||||
entry.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
# ==================== Summary Views ====================
|
||||
|
||||
|
||||
class CostSummaryByCategoryView(APIView):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
employee = _get_employee(request)
|
||||
merchant_id = request.query_params.get('merchant_id')
|
||||
start = request.query_params.get('start')
|
||||
end = request.query_params.get('end')
|
||||
|
||||
if merchant_id:
|
||||
merchant = get_object_or_404(Merchant, id=merchant_id)
|
||||
else:
|
||||
merchant = employee.merchant
|
||||
|
||||
start_date = date.fromisoformat(start) if start else None
|
||||
end_date = date.fromisoformat(end) if end else None
|
||||
|
||||
aggregated = cost_services.aggregate_by_category(
|
||||
merchant=merchant,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
)
|
||||
|
||||
return Response({
|
||||
'merchant_id': merchant.id,
|
||||
'start_date': str(start_date) if start_date else None,
|
||||
'end_date': str(end_date) if end_date else None,
|
||||
'results': aggregated,
|
||||
})
|
||||
Reference in New Issue
Block a user