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,
|
AgentMesProductionAssignmentListView,
|
||||||
)
|
)
|
||||||
from api_v2.views.basic_info import CustomerEmployeeBindingView, MyVisiblePagesView
|
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
|
from api_v2.views.wecom import WecomBindingView, WecomLoginView
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
@@ -84,6 +91,11 @@ urlpatterns = [
|
|||||||
path('mes/production-assignments/<int:assignment_id>/', ProductionAssignmentDetailView.as_view(), name='api_v2_mes_production_assignment_detail'),
|
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/', 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('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/binduser/', WecomBindingView.as_view(), name='api_v2_wecom_binduser'),
|
||||||
path('wecom/login/', WecomLoginView.as_view(), name='api_v2_wecom_login'),
|
path('wecom/login/', WecomLoginView.as_view(), name='api_v2_wecom_login'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -47,6 +47,13 @@ from .ai import (
|
|||||||
AgentMesProductionAssignmentListView,
|
AgentMesProductionAssignmentListView,
|
||||||
)
|
)
|
||||||
from .shipment_delivery_photo import ShipmentDeliveryPhotoDetailView, ShipmentDeliveryPhotoListCreateView
|
from .shipment_delivery_photo import ShipmentDeliveryPhotoDetailView, ShipmentDeliveryPhotoListCreateView
|
||||||
|
from .cost import (
|
||||||
|
CostCategoryDetailView,
|
||||||
|
CostCategoryListCreateView,
|
||||||
|
CostEntryDetailView,
|
||||||
|
CostEntryListCreateView,
|
||||||
|
CostSummaryByCategoryView,
|
||||||
|
)
|
||||||
from .wecom import WecomBindingView, WecomLoginView
|
from .wecom import WecomBindingView, WecomLoginView
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -89,4 +96,9 @@ __all__ = [
|
|||||||
'AgentMesProductionAssignmentListView',
|
'AgentMesProductionAssignmentListView',
|
||||||
'ShipmentDeliveryPhotoListCreateView',
|
'ShipmentDeliveryPhotoListCreateView',
|
||||||
'ShipmentDeliveryPhotoDetailView',
|
'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,
|
||||||
|
})
|
||||||
0
cost/__init__.py
Normal file
0
cost/__init__.py
Normal file
22
cost/admin.py
Normal file
22
cost/admin.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from . import models
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(models.CostCategory)
|
||||||
|
class CostCategoryAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('id', 'merchant', 'unique_key', 'name', 'parent', 'created_at')
|
||||||
|
search_fields = ('name', 'unique_key')
|
||||||
|
list_filter = ('merchant', 'created_at')
|
||||||
|
ordering = ('merchant', 'name')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(models.CostEntry)
|
||||||
|
class CostEntryAdmin(admin.ModelAdmin):
|
||||||
|
list_display = (
|
||||||
|
'id', 'merchant', 'category', 'amount', 'occurred_at',
|
||||||
|
'operator', 'source_module', 'source_id', 'created_at',
|
||||||
|
)
|
||||||
|
search_fields = ('category__name', 'source_module', 'source_id')
|
||||||
|
list_filter = ('merchant', 'category', 'occurred_at', 'source_module', 'created_at')
|
||||||
|
ordering = ('-occurred_at', '-created_at')
|
||||||
7
cost/apps.py
Normal file
7
cost/apps.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class CostConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'cost'
|
||||||
|
verbose_name = '成本模块'
|
||||||
58
cost/migrations/0001_initial.py
Normal file
58
cost/migrations/0001_initial.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2026-06-06 08:01
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('basic_info', '0027_employee_wecom_user_id'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='CostCategory',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||||
|
('unique_key', models.CharField(max_length=100, verbose_name='唯一标识键')),
|
||||||
|
('name', models.CharField(max_length=100, verbose_name='类目名称')),
|
||||||
|
('description', models.TextField(blank=True, null=True, verbose_name='描述')),
|
||||||
|
('merchant', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='cost_categories', to='basic_info.merchant', verbose_name='所属商户')),
|
||||||
|
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='children', to='cost.costcategory', verbose_name='父类目')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '支出类目',
|
||||||
|
'verbose_name_plural': '支出类目',
|
||||||
|
'ordering': ('merchant', 'name'),
|
||||||
|
'unique_together': {('merchant', 'unique_key')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='CostEntry',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||||
|
('amount', models.DecimalField(decimal_places=2, max_digits=15, verbose_name='金额')),
|
||||||
|
('occurred_at', models.DateField(verbose_name='发生日期')),
|
||||||
|
('image1', models.ImageField(blank=True, null=True, upload_to='cost/entries/', verbose_name='凭证图片')),
|
||||||
|
('image2', models.ImageField(blank=True, null=True, upload_to='cost/entries/', verbose_name='备用凭证图片')),
|
||||||
|
('source_module', models.CharField(blank=True, max_length=50, null=True, verbose_name='来源模块')),
|
||||||
|
('source_id', models.CharField(blank=True, max_length=100, null=True, verbose_name='来源记录ID')),
|
||||||
|
('remarks', models.TextField(blank=True, null=True, verbose_name='备注')),
|
||||||
|
('category', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='entries', to='cost.costcategory', verbose_name='支出类目')),
|
||||||
|
('merchant', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='cost_entries', to='basic_info.merchant', verbose_name='所属商户')),
|
||||||
|
('operator', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='cost_entries', to='basic_info.employee', verbose_name='经办人')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '支出明细',
|
||||||
|
'verbose_name_plural': '支出明细',
|
||||||
|
'ordering': ('-occurred_at', '-created_at'),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
cost/migrations/__init__.py
Normal file
0
cost/migrations/__init__.py
Normal file
151
cost/models.py
Normal file
151
cost/models.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
from flower.common import ModelBase
|
||||||
|
|
||||||
|
|
||||||
|
class CostCategory(ModelBase):
|
||||||
|
"""支出类目"""
|
||||||
|
|
||||||
|
merchant = models.ForeignKey(
|
||||||
|
'basic_info.Merchant',
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name='cost_categories',
|
||||||
|
verbose_name='所属商户',
|
||||||
|
)
|
||||||
|
unique_key = models.CharField(max_length=100, verbose_name='唯一标识键')
|
||||||
|
name = models.CharField(max_length=100, verbose_name='类目名称')
|
||||||
|
parent = models.ForeignKey(
|
||||||
|
'self',
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='children',
|
||||||
|
verbose_name='父类目',
|
||||||
|
)
|
||||||
|
description = models.TextField(null=True, blank=True, verbose_name='描述')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = '支出类目'
|
||||||
|
verbose_name_plural = '支出类目'
|
||||||
|
unique_together = ('merchant', 'unique_key')
|
||||||
|
ordering = ('merchant', 'name')
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.name} ({self.unique_key})'
|
||||||
|
|
||||||
|
|
||||||
|
class CostEntry(ModelBase):
|
||||||
|
"""支出明细"""
|
||||||
|
|
||||||
|
merchant = models.ForeignKey(
|
||||||
|
'basic_info.Merchant',
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name='cost_entries',
|
||||||
|
verbose_name='所属商户',
|
||||||
|
)
|
||||||
|
category = models.ForeignKey(
|
||||||
|
CostCategory,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name='entries',
|
||||||
|
verbose_name='支出类目',
|
||||||
|
)
|
||||||
|
amount = models.DecimalField(max_digits=15, decimal_places=2, verbose_name='金额')
|
||||||
|
occurred_at = models.DateField(verbose_name='发生日期')
|
||||||
|
operator = models.ForeignKey(
|
||||||
|
'basic_info.Employee',
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='cost_entries',
|
||||||
|
verbose_name='经办人',
|
||||||
|
)
|
||||||
|
image1 = models.ImageField(null=True, blank=True, upload_to='cost/entries/', verbose_name='凭证图片')
|
||||||
|
image2 = models.ImageField(null=True, blank=True, upload_to='cost/entries/', verbose_name='备用凭证图片')
|
||||||
|
source_module = models.CharField(
|
||||||
|
max_length=50, null=True, blank=True, verbose_name='来源模块',
|
||||||
|
)
|
||||||
|
source_id = models.CharField(
|
||||||
|
max_length=100, null=True, blank=True, verbose_name='来源记录ID',
|
||||||
|
)
|
||||||
|
remarks = models.TextField(null=True, blank=True, verbose_name='备注')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = '支出明细'
|
||||||
|
verbose_name_plural = '支出明细'
|
||||||
|
ordering = ('-occurred_at', '-created_at')
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.category.name} {self.amount} ({self.occurred_at})'
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 六边形端口协议 ====================
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class CostEntryInput:
|
||||||
|
"""Provider 返回的结构化成本条目"""
|
||||||
|
|
||||||
|
category_key: str
|
||||||
|
category_name: str
|
||||||
|
amount: Decimal
|
||||||
|
occurred_at: date
|
||||||
|
source_module: str
|
||||||
|
source_id: str
|
||||||
|
remarks: str = ''
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class CostProviderPort(Protocol):
|
||||||
|
"""
|
||||||
|
成本数据提供者接口。
|
||||||
|
|
||||||
|
其它模块实现此接口即可被 cost.services.collect_from_provider() 统一采集。
|
||||||
|
|
||||||
|
Provider 可以通过 category_key 声明自己使用的类目:
|
||||||
|
- 如果 cost 模块已有匹配的 category_key,直接使用
|
||||||
|
- 如果没有,cost 模块会调用 ensure_category() 自动创建
|
||||||
|
|
||||||
|
Provider 也可以反向依赖 cost 模块的基础能力:
|
||||||
|
- from cost.services import ensure_category
|
||||||
|
- 在实现 get_cost_entries 前主动调用 ensure_category 预建类目
|
||||||
|
|
||||||
|
使用示例::
|
||||||
|
|
||||||
|
from cost.models import CostProviderPort, CostEntryInput
|
||||||
|
from cost.services import ensure_category
|
||||||
|
|
||||||
|
class PrintingCostProvider:
|
||||||
|
|
||||||
|
category_key = 'printing_consumables'
|
||||||
|
|
||||||
|
def get_cost_entries(self, *, merchant, start_date, end_date):
|
||||||
|
ensure_category(
|
||||||
|
merchant=merchant,
|
||||||
|
category_key=self.category_key,
|
||||||
|
category_name='印刷耗材',
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
CostEntryInput(
|
||||||
|
category_key=self.category_key,
|
||||||
|
category_name='印刷耗材',
|
||||||
|
amount=Decimal('100.00'),
|
||||||
|
occurred_at=date.today(),
|
||||||
|
source_module='printing',
|
||||||
|
source_id='PJ-001',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
"""
|
||||||
|
|
||||||
|
category_key: str
|
||||||
|
|
||||||
|
def get_cost_entries(
|
||||||
|
self, *, merchant, start_date: date, end_date: date
|
||||||
|
) -> list[CostEntryInput]:
|
||||||
|
...
|
||||||
178
cost/services.py
Normal file
178
cost/services.py
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from django.db.models import Sum
|
||||||
|
|
||||||
|
from basic_info.models import Employee, Merchant
|
||||||
|
from . import models as cost_models
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_category(
|
||||||
|
*,
|
||||||
|
merchant: Merchant,
|
||||||
|
category_key: str,
|
||||||
|
category_name: str,
|
||||||
|
description: str = '',
|
||||||
|
) -> cost_models.CostCategory:
|
||||||
|
"""按 unique_key 查找或创建支出类目。
|
||||||
|
|
||||||
|
这是 Provider 可以反向调用的基础能力。
|
||||||
|
"""
|
||||||
|
normalized_key = str(category_key or '').strip()
|
||||||
|
normalized_name = str(category_name or '').strip()
|
||||||
|
if not normalized_key:
|
||||||
|
raise ValueError('category_key 不能为空')
|
||||||
|
if not normalized_name:
|
||||||
|
raise ValueError('category_name 不能为空')
|
||||||
|
|
||||||
|
category = cost_models.CostCategory.objects.filter(
|
||||||
|
merchant=merchant,
|
||||||
|
unique_key=normalized_key,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if category:
|
||||||
|
return category
|
||||||
|
|
||||||
|
return cost_models.CostCategory.objects.create(
|
||||||
|
merchant=merchant,
|
||||||
|
unique_key=normalized_key,
|
||||||
|
name=normalized_name,
|
||||||
|
description=description,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_cost_entry(
|
||||||
|
*,
|
||||||
|
merchant: Merchant,
|
||||||
|
category: cost_models.CostCategory,
|
||||||
|
amount: Decimal,
|
||||||
|
occurred_at: date,
|
||||||
|
operator: Employee | None = None,
|
||||||
|
image1=None,
|
||||||
|
image2=None,
|
||||||
|
source_module: str = '',
|
||||||
|
source_id: str = '',
|
||||||
|
remarks: str = '',
|
||||||
|
) -> cost_models.CostEntry:
|
||||||
|
"""创建一条支出记录。"""
|
||||||
|
if category.merchant_id != merchant.id:
|
||||||
|
raise ValueError('category 与 merchant 不属于同一商户')
|
||||||
|
if operator is not None and operator.merchant_id != merchant.id:
|
||||||
|
raise ValueError('operator 与 merchant 不属于同一商户')
|
||||||
|
|
||||||
|
return cost_models.CostEntry.objects.create(
|
||||||
|
merchant=merchant,
|
||||||
|
category=category,
|
||||||
|
amount=amount,
|
||||||
|
occurred_at=occurred_at,
|
||||||
|
operator=operator,
|
||||||
|
image1=image1,
|
||||||
|
image2=image2,
|
||||||
|
source_module=source_module or '',
|
||||||
|
source_id=source_id or '',
|
||||||
|
remarks=remarks or '',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def collect_from_provider(
|
||||||
|
provider: cost_models.CostProviderPort,
|
||||||
|
*,
|
||||||
|
merchant: Merchant,
|
||||||
|
start_date: date,
|
||||||
|
end_date: date,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""从 Provider 采集成本数据,返回写入统计。"""
|
||||||
|
entries = provider.get_cost_entries(merchant=merchant, start_date=start_date, end_date=end_date)
|
||||||
|
if not isinstance(entries, list):
|
||||||
|
raise TypeError(f'Provider.get_cost_entries 必须返回 list,实际返回 {type(entries).__name__}')
|
||||||
|
|
||||||
|
created_count = 0
|
||||||
|
errors: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for index, entry in enumerate(entries, start=1):
|
||||||
|
if not isinstance(entry, cost_models.CostEntryInput):
|
||||||
|
errors.append({
|
||||||
|
'index': index,
|
||||||
|
'error': f'条目不是 CostEntryInput 实例,实际类型: {type(entry).__name__}',
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
category = ensure_category(
|
||||||
|
merchant=merchant,
|
||||||
|
category_key=entry.category_key,
|
||||||
|
category_name=entry.category_name,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
errors.append({
|
||||||
|
'index': index,
|
||||||
|
'source_module': entry.source_module,
|
||||||
|
'source_id': entry.source_id,
|
||||||
|
'error': str(exc),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
create_cost_entry(
|
||||||
|
merchant=merchant,
|
||||||
|
category=category,
|
||||||
|
amount=entry.amount,
|
||||||
|
occurred_at=entry.occurred_at,
|
||||||
|
operator=None, # provider 采集的记录没有经办人
|
||||||
|
source_module=entry.source_module,
|
||||||
|
source_id=entry.source_id,
|
||||||
|
remarks=entry.remarks,
|
||||||
|
)
|
||||||
|
created_count += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
'provider_category_key': getattr(provider, 'category_key', ''),
|
||||||
|
'total_seen': len(entries),
|
||||||
|
'created_count': created_count,
|
||||||
|
'error_count': len(errors),
|
||||||
|
'errors': errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def aggregate_by_category(
|
||||||
|
*,
|
||||||
|
merchant: Merchant,
|
||||||
|
start_date: date | None = None,
|
||||||
|
end_date: date | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""按支出类目汇总。
|
||||||
|
|
||||||
|
返回按 total_amount 降序排列的结果列表。
|
||||||
|
"""
|
||||||
|
queryset = cost_models.CostEntry.objects.filter(merchant=merchant)
|
||||||
|
|
||||||
|
if start_date is not None:
|
||||||
|
queryset = queryset.filter(occurred_at__gte=start_date)
|
||||||
|
if end_date is not None:
|
||||||
|
queryset = queryset.filter(occurred_at__lte=end_date)
|
||||||
|
|
||||||
|
aggregated = (
|
||||||
|
queryset.values(
|
||||||
|
'category_id',
|
||||||
|
'category__name',
|
||||||
|
'category__unique_key',
|
||||||
|
)
|
||||||
|
.annotate(total_amount=Sum('amount'), entry_count=Sum(1))
|
||||||
|
.order_by('-total_amount')
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'category_id': row['category_id'],
|
||||||
|
'category_name': row['category__name'],
|
||||||
|
'category_key': row['category__unique_key'],
|
||||||
|
'total_amount': str(row['total_amount']),
|
||||||
|
'entry_count': row['entry_count'],
|
||||||
|
}
|
||||||
|
for row in aggregated
|
||||||
|
]
|
||||||
1
cost/tasks.py
Normal file
1
cost/tasks.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Celery 任务(当前版本预留,暂无定时任务)。"""
|
||||||
0
cost/tests/__init__.py
Normal file
0
cost/tests/__init__.py
Normal file
145
cost/tests/test_models.py
Normal file
145
cost/tests/test_models.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
"""Cost 模块模型测试"""
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.db import IntegrityError
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from basic_info import models as basic_models
|
||||||
|
from cost import models as cost_models
|
||||||
|
|
||||||
|
|
||||||
|
class CostCategoryModelTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.merchant = basic_models.Merchant.objects.create(
|
||||||
|
name='测试商户', type=basic_models.MerchantTypeEnum.STORE,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_str_representation(self):
|
||||||
|
cat = cost_models.CostCategory.objects.create(
|
||||||
|
merchant=self.merchant, unique_key='electricity', name='电费',
|
||||||
|
)
|
||||||
|
self.assertEqual(str(cat), '电费 (electricity)')
|
||||||
|
|
||||||
|
def test_unique_together_merchant_key(self):
|
||||||
|
"""同一商户 same unique_key 不可重复"""
|
||||||
|
cost_models.CostCategory.objects.create(
|
||||||
|
merchant=self.merchant, unique_key='electricity', name='电费',
|
||||||
|
)
|
||||||
|
with self.assertRaises(IntegrityError):
|
||||||
|
cost_models.CostCategory.objects.create(
|
||||||
|
merchant=self.merchant, unique_key='electricity', name='电费2',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_different_merchants_same_key_allowed(self):
|
||||||
|
"""不同商户 same unique_key 允许"""
|
||||||
|
other = basic_models.Merchant.objects.create(
|
||||||
|
name='其他商户', type=basic_models.MerchantTypeEnum.STORE,
|
||||||
|
)
|
||||||
|
cat1 = cost_models.CostCategory.objects.create(
|
||||||
|
merchant=self.merchant, unique_key='electricity', name='电费',
|
||||||
|
)
|
||||||
|
cat2 = cost_models.CostCategory.objects.create(
|
||||||
|
merchant=other, unique_key='electricity', name='电费',
|
||||||
|
)
|
||||||
|
self.assertNotEqual(cat1.id, cat2.id)
|
||||||
|
|
||||||
|
def test_parent_self_reference(self):
|
||||||
|
"""父类目 self-FK"""
|
||||||
|
parent = cost_models.CostCategory.objects.create(
|
||||||
|
merchant=self.merchant, unique_key='utilities', name='公共事业',
|
||||||
|
)
|
||||||
|
child = cost_models.CostCategory.objects.create(
|
||||||
|
merchant=self.merchant, unique_key='electricity', name='电费',
|
||||||
|
parent=parent,
|
||||||
|
)
|
||||||
|
self.assertEqual(child.parent_id, parent.id)
|
||||||
|
self.assertEqual(list(parent.children.all()), [child])
|
||||||
|
|
||||||
|
def test_description_nullable(self):
|
||||||
|
cat = cost_models.CostCategory.objects.create(
|
||||||
|
merchant=self.merchant, unique_key='key', name='name',
|
||||||
|
)
|
||||||
|
self.assertIsNone(cat.description)
|
||||||
|
|
||||||
|
def test_ordering_by_merchant_then_name(self):
|
||||||
|
other = basic_models.Merchant.objects.create(
|
||||||
|
name='其他商户', type=basic_models.MerchantTypeEnum.STORE,
|
||||||
|
)
|
||||||
|
cost_models.CostCategory.objects.create(
|
||||||
|
merchant=self.merchant, unique_key='z', name='Z类目',
|
||||||
|
)
|
||||||
|
cost_models.CostCategory.objects.create(
|
||||||
|
merchant=self.merchant, unique_key='a', name='A类目',
|
||||||
|
)
|
||||||
|
cost_models.CostCategory.objects.create(
|
||||||
|
merchant=other, unique_key='m', name='M类目',
|
||||||
|
)
|
||||||
|
# ordering = ('merchant', 'name') — 同一个 merchant 内按 name 排序
|
||||||
|
own = cost_models.CostCategory.objects.filter(merchant=self.merchant)
|
||||||
|
own_names = [c.name for c in own]
|
||||||
|
self.assertEqual(own_names, ['A类目', 'Z类目']) # name 升序
|
||||||
|
|
||||||
|
|
||||||
|
class CostEntryModelTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.merchant = basic_models.Merchant.objects.create(
|
||||||
|
name='测试商户', type=basic_models.MerchantTypeEnum.STORE,
|
||||||
|
)
|
||||||
|
self.category = cost_models.CostCategory.objects.create(
|
||||||
|
merchant=self.merchant, unique_key='transport', name='运输费',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_str_representation(self):
|
||||||
|
entry = cost_models.CostEntry.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
category=self.category,
|
||||||
|
amount=Decimal('150.00'),
|
||||||
|
occurred_at=date(2026, 6, 1),
|
||||||
|
)
|
||||||
|
self.assertIn('运输费', str(entry))
|
||||||
|
self.assertIn('150.00', str(entry))
|
||||||
|
|
||||||
|
def test_default_ordering_by_occurred_at_desc(self):
|
||||||
|
e1 = cost_models.CostEntry.objects.create(
|
||||||
|
merchant=self.merchant, category=self.category,
|
||||||
|
amount=Decimal('100.00'), occurred_at=date(2026, 6, 1),
|
||||||
|
)
|
||||||
|
e2 = cost_models.CostEntry.objects.create(
|
||||||
|
merchant=self.merchant, category=self.category,
|
||||||
|
amount=Decimal('200.00'), occurred_at=date(2026, 6, 10),
|
||||||
|
)
|
||||||
|
entries = list(cost_models.CostEntry.objects.all())
|
||||||
|
self.assertEqual(entries[0].id, e2.id)
|
||||||
|
self.assertEqual(entries[1].id, e1.id)
|
||||||
|
|
||||||
|
def test_operator_nullable(self):
|
||||||
|
entry = cost_models.CostEntry.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
category=self.category,
|
||||||
|
amount=Decimal('100.00'),
|
||||||
|
occurred_at=date(2026, 6, 1),
|
||||||
|
operator=None,
|
||||||
|
)
|
||||||
|
self.assertIsNone(entry.operator_id)
|
||||||
|
|
||||||
|
def test_source_fields_nullable(self):
|
||||||
|
entry = cost_models.CostEntry.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
category=self.category,
|
||||||
|
amount=Decimal('100.00'),
|
||||||
|
occurred_at=date(2026, 6, 1),
|
||||||
|
)
|
||||||
|
self.assertIsNone(entry.source_module)
|
||||||
|
self.assertIsNone(entry.source_id)
|
||||||
|
|
||||||
|
def test_image_fields_nullable(self):
|
||||||
|
entry = cost_models.CostEntry.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
category=self.category,
|
||||||
|
amount=Decimal('100.00'),
|
||||||
|
occurred_at=date(2026, 6, 1),
|
||||||
|
)
|
||||||
|
self.assertFalse(bool(entry.image1))
|
||||||
|
self.assertFalse(bool(entry.image2))
|
||||||
445
cost/tests/test_services.py
Normal file
445
cost/tests/test_services.py
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
"""Cost 模块服务测试"""
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from basic_info import models as basic_models
|
||||||
|
from cost import models as cost_models
|
||||||
|
from cost import services as cost_services
|
||||||
|
from cost.models import CostEntryInput, CostProviderPort
|
||||||
|
|
||||||
|
|
||||||
|
class EnsureCategoryTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_ensure_category_creates_new(self):
|
||||||
|
"""category_key 不存在时创建新类目"""
|
||||||
|
cat = cost_services.ensure_category(
|
||||||
|
merchant=self.merchant,
|
||||||
|
category_key='electricity',
|
||||||
|
category_name='电费',
|
||||||
|
description='每月电费支出',
|
||||||
|
)
|
||||||
|
self.assertEqual(cat.unique_key, 'electricity')
|
||||||
|
self.assertEqual(cat.name, '电费')
|
||||||
|
self.assertEqual(cat.description, '每月电费支出')
|
||||||
|
self.assertEqual(cat.merchant_id, self.merchant.id)
|
||||||
|
|
||||||
|
def test_ensure_category_returns_existing(self):
|
||||||
|
"""category_key 已存在时返回已有类目"""
|
||||||
|
existing = cost_models.CostCategory.objects.create(
|
||||||
|
merchant=self.merchant, unique_key='electricity', name='电费',
|
||||||
|
)
|
||||||
|
cat = cost_services.ensure_category(
|
||||||
|
merchant=self.merchant, category_key='electricity', category_name='电力',
|
||||||
|
)
|
||||||
|
self.assertEqual(cat.id, existing.id)
|
||||||
|
self.assertEqual(cat.name, '电费') # 不覆盖已有 name
|
||||||
|
|
||||||
|
def test_ensure_category_empty_key_raises(self):
|
||||||
|
"""空 category_key 抛 ValueError"""
|
||||||
|
with self.assertRaises(ValueError) as ctx:
|
||||||
|
cost_services.ensure_category(
|
||||||
|
merchant=self.merchant, category_key='', category_name='电费',
|
||||||
|
)
|
||||||
|
self.assertIn('category_key', str(ctx.exception))
|
||||||
|
|
||||||
|
def test_ensure_category_whitespace_key_raises(self):
|
||||||
|
"""纯空格 category_key 抛 ValueError"""
|
||||||
|
with self.assertRaises(ValueError) as ctx:
|
||||||
|
cost_services.ensure_category(
|
||||||
|
merchant=self.merchant, category_key=' ', category_name='电费',
|
||||||
|
)
|
||||||
|
self.assertIn('category_key', str(ctx.exception))
|
||||||
|
|
||||||
|
def test_ensure_category_empty_name_raises(self):
|
||||||
|
"""空 category_name 抛 ValueError"""
|
||||||
|
with self.assertRaises(ValueError) as ctx:
|
||||||
|
cost_services.ensure_category(
|
||||||
|
merchant=self.merchant, category_key='key', category_name='',
|
||||||
|
)
|
||||||
|
self.assertIn('category_name', str(ctx.exception))
|
||||||
|
|
||||||
|
def test_ensure_category_different_merchants_same_key(self):
|
||||||
|
"""不同商户相同 key 不冲突"""
|
||||||
|
cat1 = cost_services.ensure_category(
|
||||||
|
merchant=self.merchant, category_key='same_key', category_name='类目A',
|
||||||
|
)
|
||||||
|
cat2 = cost_services.ensure_category(
|
||||||
|
merchant=self.other_merchant, category_key='same_key', category_name='类目B',
|
||||||
|
)
|
||||||
|
self.assertNotEqual(cat1.id, cat2.id)
|
||||||
|
self.assertEqual(cat1.name, '类目A')
|
||||||
|
self.assertEqual(cat2.name, '类目B')
|
||||||
|
|
||||||
|
|
||||||
|
class CreateCostEntryTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
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='testuser', password='pass12345')
|
||||||
|
self.employee = basic_models.Employee.objects.create(
|
||||||
|
merchant=self.merchant, sys_user=self.user, name='测试员工',
|
||||||
|
)
|
||||||
|
self.other_user = get_user_model().objects.create_user(username='otheruser', password='pass12345')
|
||||||
|
self.other_employee = basic_models.Employee.objects.create(
|
||||||
|
merchant=self.other_merchant, sys_user=self.other_user, name='其他员工',
|
||||||
|
)
|
||||||
|
self.category = cost_models.CostCategory.objects.create(
|
||||||
|
merchant=self.merchant, unique_key='transport', name='运输费',
|
||||||
|
)
|
||||||
|
self.other_category = cost_models.CostCategory.objects.create(
|
||||||
|
merchant=self.other_merchant, unique_key='transport', name='运输费',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_create_cost_entry_normal(self):
|
||||||
|
"""正常创建支出记录"""
|
||||||
|
entry = cost_services.create_cost_entry(
|
||||||
|
merchant=self.merchant,
|
||||||
|
category=self.category,
|
||||||
|
amount=Decimal('150.00'),
|
||||||
|
occurred_at=date(2026, 6, 1),
|
||||||
|
operator=self.employee,
|
||||||
|
source_module='manual',
|
||||||
|
source_id='',
|
||||||
|
remarks='测试备注',
|
||||||
|
)
|
||||||
|
self.assertEqual(entry.amount, Decimal('150.00'))
|
||||||
|
self.assertEqual(entry.occurred_at, date(2026, 6, 1))
|
||||||
|
self.assertEqual(entry.operator_id, self.employee.id)
|
||||||
|
self.assertEqual(entry.merchant_id, self.merchant.id)
|
||||||
|
self.assertEqual(entry.source_module, 'manual')
|
||||||
|
self.assertEqual(entry.remarks, '测试备注')
|
||||||
|
|
||||||
|
def test_create_cost_entry_operator_none(self):
|
||||||
|
"""operator=None 正常创建(provider 场景)"""
|
||||||
|
entry = cost_services.create_cost_entry(
|
||||||
|
merchant=self.merchant,
|
||||||
|
category=self.category,
|
||||||
|
amount=Decimal('200.00'),
|
||||||
|
occurred_at=date(2026, 6, 2),
|
||||||
|
operator=None,
|
||||||
|
)
|
||||||
|
self.assertIsNone(entry.operator_id)
|
||||||
|
self.assertEqual(entry.amount, Decimal('200.00'))
|
||||||
|
|
||||||
|
def test_create_cost_entry_category_mismatch_merchant_raises(self):
|
||||||
|
"""category 所属商户与 merchant 不一致抛 ValueError"""
|
||||||
|
with self.assertRaises(ValueError) as ctx:
|
||||||
|
cost_services.create_cost_entry(
|
||||||
|
merchant=self.merchant,
|
||||||
|
category=self.other_category,
|
||||||
|
amount=Decimal('100.00'),
|
||||||
|
occurred_at=date(2026, 6, 1),
|
||||||
|
operator=self.employee,
|
||||||
|
)
|
||||||
|
self.assertIn('category', str(ctx.exception))
|
||||||
|
|
||||||
|
def test_create_cost_entry_operator_mismatch_merchant_raises(self):
|
||||||
|
"""operator 所属商户与 merchant 不一致抛 ValueError"""
|
||||||
|
with self.assertRaises(ValueError) as ctx:
|
||||||
|
cost_services.create_cost_entry(
|
||||||
|
merchant=self.merchant,
|
||||||
|
category=self.category,
|
||||||
|
amount=Decimal('100.00'),
|
||||||
|
occurred_at=date(2026, 6, 1),
|
||||||
|
operator=self.other_employee,
|
||||||
|
)
|
||||||
|
self.assertIn('operator', str(ctx.exception))
|
||||||
|
|
||||||
|
def test_create_cost_entry_default_values(self):
|
||||||
|
"""不传 source_module/source_id/remarks 时默认为空字符串"""
|
||||||
|
entry = cost_services.create_cost_entry(
|
||||||
|
merchant=self.merchant,
|
||||||
|
category=self.category,
|
||||||
|
amount=Decimal('50.00'),
|
||||||
|
occurred_at=date(2026, 6, 3),
|
||||||
|
)
|
||||||
|
self.assertEqual(entry.source_module, '')
|
||||||
|
self.assertEqual(entry.source_id, '')
|
||||||
|
self.assertEqual(entry.remarks, '')
|
||||||
|
|
||||||
|
|
||||||
|
class MockCostProvider:
|
||||||
|
"""测试用 Provider"""
|
||||||
|
category_key = 'test_provider'
|
||||||
|
|
||||||
|
def __init__(self, entries):
|
||||||
|
self._entries = entries
|
||||||
|
|
||||||
|
def get_cost_entries(self, *, merchant, start_date, end_date):
|
||||||
|
return self._entries
|
||||||
|
|
||||||
|
|
||||||
|
class CollectFromProviderTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.merchant = basic_models.Merchant.objects.create(
|
||||||
|
name='测试商户', type=basic_models.MerchantTypeEnum.STORE,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_collect_creates_entries(self):
|
||||||
|
"""正常采集并创建 CostEntry"""
|
||||||
|
entries = [
|
||||||
|
CostEntryInput(
|
||||||
|
category_key='electricity', category_name='电费',
|
||||||
|
amount=Decimal('300.00'), occurred_at=date(2026, 6, 1),
|
||||||
|
source_module='test', source_id='E001',
|
||||||
|
),
|
||||||
|
CostEntryInput(
|
||||||
|
category_key='water', category_name='水费',
|
||||||
|
amount=Decimal('80.00'), occurred_at=date(2026, 6, 2),
|
||||||
|
source_module='test', source_id='W001',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
provider = MockCostProvider(entries)
|
||||||
|
|
||||||
|
result = cost_services.collect_from_provider(
|
||||||
|
provider=provider,
|
||||||
|
merchant=self.merchant,
|
||||||
|
start_date=date(2026, 6, 1),
|
||||||
|
end_date=date(2026, 6, 30),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result['total_seen'], 2)
|
||||||
|
self.assertEqual(result['created_count'], 2)
|
||||||
|
self.assertEqual(result['error_count'], 0)
|
||||||
|
self.assertEqual(len(result['errors']), 0)
|
||||||
|
self.assertEqual(result['provider_category_key'], 'test_provider')
|
||||||
|
|
||||||
|
# 验证数据库
|
||||||
|
self.assertEqual(cost_models.CostEntry.objects.count(), 2)
|
||||||
|
self.assertEqual(cost_models.CostCategory.objects.count(), 2)
|
||||||
|
|
||||||
|
def test_collect_returns_not_list_raises(self):
|
||||||
|
"""Provider 返回非 list 抛 TypeError"""
|
||||||
|
class BadProvider:
|
||||||
|
category_key = 'bad'
|
||||||
|
def get_cost_entries(self, *, merchant, start_date, end_date):
|
||||||
|
return 'not_a_list'
|
||||||
|
|
||||||
|
with self.assertRaises(TypeError):
|
||||||
|
cost_services.collect_from_provider(
|
||||||
|
provider=BadProvider(),
|
||||||
|
merchant=self.merchant,
|
||||||
|
start_date=date(2026, 6, 1),
|
||||||
|
end_date=date(2026, 6, 30),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_collect_non_entry_input_records_error(self):
|
||||||
|
"""非 CostEntryInput 的条目记入 errors"""
|
||||||
|
provider = MockCostProvider(['not_a_dataclass', 123])
|
||||||
|
|
||||||
|
result = cost_services.collect_from_provider(
|
||||||
|
provider=provider,
|
||||||
|
merchant=self.merchant,
|
||||||
|
start_date=date(2026, 6, 1),
|
||||||
|
end_date=date(2026, 6, 30),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result['total_seen'], 2)
|
||||||
|
self.assertEqual(result['created_count'], 0)
|
||||||
|
self.assertEqual(result['error_count'], 2)
|
||||||
|
self.assertEqual(cost_models.CostEntry.objects.count(), 0)
|
||||||
|
|
||||||
|
def test_collect_empty_category_key_records_error(self):
|
||||||
|
"""category_key 为空导致 ValueError 记入 errors"""
|
||||||
|
entries = [
|
||||||
|
CostEntryInput(
|
||||||
|
category_key='', category_name='无key类目',
|
||||||
|
amount=Decimal('100.00'), occurred_at=date(2026, 6, 1),
|
||||||
|
source_module='test', source_id='X001',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
provider = MockCostProvider(entries)
|
||||||
|
|
||||||
|
result = cost_services.collect_from_provider(
|
||||||
|
provider=provider,
|
||||||
|
merchant=self.merchant,
|
||||||
|
start_date=date(2026, 6, 1),
|
||||||
|
end_date=date(2026, 6, 30),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result['total_seen'], 1)
|
||||||
|
self.assertEqual(result['created_count'], 0)
|
||||||
|
self.assertEqual(result['error_count'], 1)
|
||||||
|
self.assertIn('category_key', result['errors'][0]['error'])
|
||||||
|
|
||||||
|
def test_collect_empty_list(self):
|
||||||
|
"""空列表返回 created_count=0"""
|
||||||
|
provider = MockCostProvider([])
|
||||||
|
|
||||||
|
result = cost_services.collect_from_provider(
|
||||||
|
provider=provider,
|
||||||
|
merchant=self.merchant,
|
||||||
|
start_date=date(2026, 6, 1),
|
||||||
|
end_date=date(2026, 6, 30),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result['total_seen'], 0)
|
||||||
|
self.assertEqual(result['created_count'], 0)
|
||||||
|
self.assertEqual(result['error_count'], 0)
|
||||||
|
|
||||||
|
def test_collect_mixed_valid_invalid_partial_success(self):
|
||||||
|
"""混合合法+非法条目,合法条目正常创建"""
|
||||||
|
entries = [
|
||||||
|
CostEntryInput(
|
||||||
|
category_key='electricity', category_name='电费',
|
||||||
|
amount=Decimal('300.00'), occurred_at=date(2026, 6, 1),
|
||||||
|
source_module='test', source_id='E001',
|
||||||
|
),
|
||||||
|
'invalid_item',
|
||||||
|
CostEntryInput(
|
||||||
|
category_key='', category_name='',
|
||||||
|
amount=Decimal('50.00'), occurred_at=date(2026, 6, 2),
|
||||||
|
source_module='test', source_id='X002',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
provider = MockCostProvider(entries)
|
||||||
|
|
||||||
|
result = cost_services.collect_from_provider(
|
||||||
|
provider=provider,
|
||||||
|
merchant=self.merchant,
|
||||||
|
start_date=date(2026, 6, 1),
|
||||||
|
end_date=date(2026, 6, 30),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result['total_seen'], 3)
|
||||||
|
self.assertEqual(result['created_count'], 1)
|
||||||
|
self.assertEqual(result['error_count'], 2)
|
||||||
|
self.assertEqual(cost_models.CostEntry.objects.count(), 1)
|
||||||
|
|
||||||
|
def test_collect_reuses_existing_category(self):
|
||||||
|
"""已有类目时不重复创建"""
|
||||||
|
cost_models.CostCategory.objects.create(
|
||||||
|
merchant=self.merchant, unique_key='electricity', name='电费旧名',
|
||||||
|
)
|
||||||
|
entries = [
|
||||||
|
CostEntryInput(
|
||||||
|
category_key='electricity', category_name='电费新名',
|
||||||
|
amount=Decimal('100.00'), occurred_at=date(2026, 6, 1),
|
||||||
|
source_module='test', source_id='E001',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
provider = MockCostProvider(entries)
|
||||||
|
|
||||||
|
cost_services.collect_from_provider(
|
||||||
|
provider=provider,
|
||||||
|
merchant=self.merchant,
|
||||||
|
start_date=date(2026, 6, 1),
|
||||||
|
end_date=date(2026, 6, 30),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(cost_models.CostCategory.objects.count(), 1)
|
||||||
|
cat = cost_models.CostCategory.objects.first()
|
||||||
|
self.assertEqual(cat.name, '电费旧名') # 不覆盖已有 name
|
||||||
|
|
||||||
|
|
||||||
|
class AggregateByCategoryTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
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.cat_a = cost_models.CostCategory.objects.create(
|
||||||
|
merchant=self.merchant, unique_key='electricity', name='电费',
|
||||||
|
)
|
||||||
|
self.cat_b = cost_models.CostCategory.objects.create(
|
||||||
|
merchant=self.merchant, unique_key='water', name='水费',
|
||||||
|
)
|
||||||
|
|
||||||
|
def _create_entries(self):
|
||||||
|
"""创建测试数据:电费 300+200=500,水费 80,其他商户 1000"""
|
||||||
|
cost_models.CostEntry.objects.create(
|
||||||
|
merchant=self.merchant, category=self.cat_a,
|
||||||
|
amount=Decimal('300.00'), occurred_at=date(2026, 6, 1),
|
||||||
|
)
|
||||||
|
cost_models.CostEntry.objects.create(
|
||||||
|
merchant=self.merchant, category=self.cat_a,
|
||||||
|
amount=Decimal('200.00'), occurred_at=date(2026, 6, 10),
|
||||||
|
)
|
||||||
|
cost_models.CostEntry.objects.create(
|
||||||
|
merchant=self.merchant, category=self.cat_b,
|
||||||
|
amount=Decimal('80.00'), occurred_at=date(2026, 6, 5),
|
||||||
|
)
|
||||||
|
other_cat = cost_models.CostCategory.objects.create(
|
||||||
|
merchant=self.other_merchant, unique_key='other', name='其他',
|
||||||
|
)
|
||||||
|
cost_models.CostEntry.objects.create(
|
||||||
|
merchant=self.other_merchant, category=other_cat,
|
||||||
|
amount=Decimal('1000.00'), occurred_at=date(2026, 6, 15),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_aggregate_by_category_desc_order(self):
|
||||||
|
"""按 total_amount 降序,只统计本商户"""
|
||||||
|
self._create_entries()
|
||||||
|
result = cost_services.aggregate_by_category(merchant=self.merchant)
|
||||||
|
|
||||||
|
self.assertEqual(len(result), 2)
|
||||||
|
self.assertEqual(result[0]['category_name'], '电费')
|
||||||
|
self.assertEqual(result[0]['total_amount'], '500.00')
|
||||||
|
self.assertEqual(result[0]['entry_count'], 2)
|
||||||
|
self.assertEqual(result[1]['category_name'], '水费')
|
||||||
|
self.assertEqual(result[1]['total_amount'], '80.00')
|
||||||
|
self.assertEqual(result[1]['entry_count'], 1)
|
||||||
|
|
||||||
|
def test_aggregate_by_category_with_start_date(self):
|
||||||
|
"""start_date 过滤"""
|
||||||
|
self._create_entries()
|
||||||
|
result = cost_services.aggregate_by_category(
|
||||||
|
merchant=self.merchant,
|
||||||
|
start_date=date(2026, 6, 5),
|
||||||
|
)
|
||||||
|
self.assertEqual(len(result), 2)
|
||||||
|
totals = {r['category_name']: r['total_amount'] for r in result}
|
||||||
|
self.assertEqual(totals['电费'], '200.00') # 只有 6.10 那条
|
||||||
|
self.assertEqual(totals['水费'], '80.00')
|
||||||
|
|
||||||
|
def test_aggregate_by_category_with_end_date(self):
|
||||||
|
"""end_date 过滤"""
|
||||||
|
self._create_entries()
|
||||||
|
result = cost_services.aggregate_by_category(
|
||||||
|
merchant=self.merchant,
|
||||||
|
end_date=date(2026, 6, 5),
|
||||||
|
)
|
||||||
|
self.assertEqual(len(result), 2)
|
||||||
|
totals = {r['category_name']: r['total_amount'] for r in result}
|
||||||
|
self.assertEqual(totals['电费'], '300.00') # 只有 6.1 那条
|
||||||
|
self.assertEqual(totals['水费'], '80.00')
|
||||||
|
|
||||||
|
def test_aggregate_by_category_with_both_dates(self):
|
||||||
|
"""start_date + end_date 同时过滤"""
|
||||||
|
self._create_entries()
|
||||||
|
result = cost_services.aggregate_by_category(
|
||||||
|
merchant=self.merchant,
|
||||||
|
start_date=date(2026, 6, 5),
|
||||||
|
end_date=date(2026, 6, 8),
|
||||||
|
)
|
||||||
|
self.assertEqual(len(result), 1)
|
||||||
|
self.assertEqual(result[0]['category_name'], '水费')
|
||||||
|
self.assertEqual(result[0]['total_amount'], '80.00')
|
||||||
|
|
||||||
|
def test_aggregate_no_data_returns_empty(self):
|
||||||
|
"""无数据返回空列表"""
|
||||||
|
result = cost_services.aggregate_by_category(merchant=self.merchant)
|
||||||
|
self.assertEqual(result, [])
|
||||||
|
|
||||||
|
def test_aggregate_includes_category_key(self):
|
||||||
|
"""返回结果包含 category_key"""
|
||||||
|
self._create_entries()
|
||||||
|
result = cost_services.aggregate_by_category(merchant=self.merchant)
|
||||||
|
self.assertEqual(result[0]['category_key'], 'electricity')
|
||||||
|
self.assertEqual(result[1]['category_key'], 'water')
|
||||||
329
docs/cost/api.md
Normal file
329
docs/cost/api.md
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
# API v2 成本模块接口文档
|
||||||
|
|
||||||
|
本文档面向前端,描述 `cost` 成本模块的 API。
|
||||||
|
|
||||||
|
## 基本约定
|
||||||
|
|
||||||
|
- Base URL: `/api/v2`
|
||||||
|
- 认证:所有接口都需要登录(JWT)。
|
||||||
|
- 人员身份:后端使用 `request.user.employee` 作为当前员工身份。
|
||||||
|
- 多商户隔离:所有类目、支出明细只允许访问当前员工所属商户的数据;可通过 `?merchant_id=` 参数跨商户查询(如果权限允许)。
|
||||||
|
- 时间格式:请求参数中的日期使用 `YYYY-MM-DD`(如 `2026-06-01`),响应中的日期时间使用 ISO 8601。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 支出类目
|
||||||
|
|
||||||
|
### 1.1 类目列表
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v2/cost-categories/
|
||||||
|
```
|
||||||
|
|
||||||
|
查询参数:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `merchant_id` | int | 否 | 按商户筛选,不传则使用当前员工的商户 |
|
||||||
|
|
||||||
|
成功响应 `200`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"merchant_id": 1,
|
||||||
|
"unique_key": "electricity",
|
||||||
|
"name": "电费",
|
||||||
|
"parent_id": null,
|
||||||
|
"description": "每月电费支出",
|
||||||
|
"created_at": "2026-06-06T10:00:00Z",
|
||||||
|
"updated_at": "2026-06-06T10:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 创建类目
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v2/cost-categories/
|
||||||
|
```
|
||||||
|
|
||||||
|
请求体(JSON):
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `unique_key` | string | **是** | 唯一标识键,同商户不可重复,如 `electricity` |
|
||||||
|
| `name` | string | **是** | 类目名称 |
|
||||||
|
| `parent_id` | int | 否 | 父类目 ID,用于层级 |
|
||||||
|
| `description` | string | 否 | 描述 |
|
||||||
|
|
||||||
|
请求示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"unique_key": "electricity",
|
||||||
|
"name": "电费",
|
||||||
|
"description": "每月电费支出"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
成功响应 `201`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"merchant_id": 1,
|
||||||
|
"unique_key": "electricity",
|
||||||
|
"name": "电费",
|
||||||
|
"parent_id": null,
|
||||||
|
"description": "每月电费支出",
|
||||||
|
"created_at": "2026-06-06T10:00:00Z",
|
||||||
|
"updated_at": "2026-06-06T10:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
错误响应 `400`:缺少必填字段时返回校验错误。
|
||||||
|
|
||||||
|
### 1.3 类目详情
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v2/cost-categories/{category_id}/
|
||||||
|
```
|
||||||
|
|
||||||
|
成功响应 `200`:返回单个类目对象,结构同上。
|
||||||
|
|
||||||
|
错误响应 `404`:类目不存在或不属于当前商户。
|
||||||
|
|
||||||
|
### 1.4 修改类目
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /api/v2/cost-categories/{category_id}/
|
||||||
|
```
|
||||||
|
|
||||||
|
请求体(JSON,所有字段可选):
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `unique_key` | string | 唯一标识键 |
|
||||||
|
| `name` | string | 类目名称 |
|
||||||
|
| `parent_id` | int | 父类目 ID,传 `null` 清除 |
|
||||||
|
| `description` | string | 描述 |
|
||||||
|
|
||||||
|
成功响应 `200`:返回更新后的类目对象。
|
||||||
|
|
||||||
|
### 1.5 删除类目
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /api/v2/cost-categories/{category_id}/
|
||||||
|
```
|
||||||
|
|
||||||
|
成功响应 `204`(无响应体)。
|
||||||
|
|
||||||
|
错误响应 `404`:类目不存在或不属于当前商户。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 支出明细
|
||||||
|
|
||||||
|
### 2.1 支出明细列表
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v2/cost-entries/
|
||||||
|
```
|
||||||
|
|
||||||
|
查询参数:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `merchant_id` | int | 否 | 按商户筛选 |
|
||||||
|
| `category_id` | int | 否 | 按支出类目筛选 |
|
||||||
|
| `start` | date | 否 | 发生日期起始,格式 `YYYY-MM-DD` |
|
||||||
|
| `end` | date | 否 | 发生日期截止,格式 `YYYY-MM-DD` |
|
||||||
|
|
||||||
|
默认按 `occurred_at` 降序排列。
|
||||||
|
|
||||||
|
成功响应 `200`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"merchant_id": 1,
|
||||||
|
"category_id": 1,
|
||||||
|
"category_name": "电费",
|
||||||
|
"amount": "350.00",
|
||||||
|
"occurred_at": "2026-06-01",
|
||||||
|
"operator_id": 12,
|
||||||
|
"image1": "",
|
||||||
|
"image2": "",
|
||||||
|
"source_module": "",
|
||||||
|
"source_id": "",
|
||||||
|
"remarks": "六月份电费",
|
||||||
|
"created_at": "2026-06-06T10:00:00Z",
|
||||||
|
"updated_at": "2026-06-06T10:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
字段说明:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `amount` | string | 金额(Decimal 转字符串,前端展示时注意格式化) |
|
||||||
|
| `occurred_at` | string | 发生日期,`YYYY-MM-DD` |
|
||||||
|
| `operator_id` | int | 经办人 ID,自动从当前登录用户获取 |
|
||||||
|
| `image1` | string | 凭证图片 URL(七牛云 CDN),无则为空字符串 |
|
||||||
|
| `image2` | string | 备用凭证图片 URL,无则为空字符串 |
|
||||||
|
| `source_module` | string | 来源模块名,手工录入时为空 |
|
||||||
|
| `source_id` | string | 来源记录 ID,手工录入时为空 |
|
||||||
|
|
||||||
|
### 2.2 创建支出明细
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v2/cost-entries/
|
||||||
|
```
|
||||||
|
|
||||||
|
Content-Type: `multipart/form-data`(支持图片上传)或 `application/json`
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `category_id` | int | **是** | 支出类目 ID |
|
||||||
|
| `amount` | decimal | **是** | 金额,如 `"350.00"` |
|
||||||
|
| `occurred_at` | date | **是** | 发生日期,`YYYY-MM-DD` |
|
||||||
|
| `image1` | file | 否 | 凭证图片 |
|
||||||
|
| `image2` | file | 否 | 备用凭证图片 |
|
||||||
|
| `source_module` | string | 否 | 来源模块名 |
|
||||||
|
| `source_id` | string | 否 | 来源记录 ID |
|
||||||
|
| `remarks` | string | 否 | 备注 |
|
||||||
|
|
||||||
|
请求示例(JSON):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"category_id": 1,
|
||||||
|
"amount": "350.00",
|
||||||
|
"occurred_at": "2026-06-01",
|
||||||
|
"remarks": "六月份电费"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
成功响应 `201`:返回创建的支出明细对象。
|
||||||
|
|
||||||
|
错误响应 `400`:校验失败。
|
||||||
|
错误响应 `404`:`category_id` 不存在或不属于当前商户。
|
||||||
|
|
||||||
|
### 2.3 支出明细详情
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v2/cost-entries/{entry_id}/
|
||||||
|
```
|
||||||
|
|
||||||
|
成功响应 `200`:返回单个支出明细对象,含 `category_name`。
|
||||||
|
|
||||||
|
错误响应 `404`:明细不存在或不属于当前商户。
|
||||||
|
|
||||||
|
### 2.4 修改支出明细
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /api/v2/cost-entries/{entry_id}/
|
||||||
|
```
|
||||||
|
|
||||||
|
Content-Type: `multipart/form-data` 或 `application/json`
|
||||||
|
|
||||||
|
请求体(所有字段可选):
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `category_id` | int | 切换类目 |
|
||||||
|
| `amount` | decimal | 金额 |
|
||||||
|
| `occurred_at` | date | 发生日期 |
|
||||||
|
| `image1` | file | 凭证图片 |
|
||||||
|
| `image2` | file | 备用凭证图片 |
|
||||||
|
| `source_module` | string | 来源模块名 |
|
||||||
|
| `source_id` | string | 来源记录 ID |
|
||||||
|
| `remarks` | string | 备注 |
|
||||||
|
|
||||||
|
成功响应 `200`:返回更新后的支出明细对象。
|
||||||
|
|
||||||
|
### 2.5 删除支出明细
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /api/v2/cost-entries/{entry_id}/
|
||||||
|
```
|
||||||
|
|
||||||
|
成功响应 `204`。
|
||||||
|
|
||||||
|
错误响应 `404`:明细不存在或不属于当前商户。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 按类目汇总
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v2/cost-summary/by-category/
|
||||||
|
```
|
||||||
|
|
||||||
|
查询参数:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `merchant_id` | int | 否 | 按商户筛选 |
|
||||||
|
| `start` | date | 否 | 发生日期起始 |
|
||||||
|
| `end` | date | 否 | 发生日期截止 |
|
||||||
|
|
||||||
|
按 `total_amount` 降序排列。
|
||||||
|
|
||||||
|
成功响应 `200`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"merchant_id": 1,
|
||||||
|
"start_date": "2026-06-01",
|
||||||
|
"end_date": "2026-06-30",
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"category_id": 1,
|
||||||
|
"category_name": "电费",
|
||||||
|
"category_key": "electricity",
|
||||||
|
"total_amount": "500.00",
|
||||||
|
"entry_count": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category_id": 2,
|
||||||
|
"category_name": "水费",
|
||||||
|
"category_key": "water",
|
||||||
|
"total_amount": "80.00",
|
||||||
|
"entry_count": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
字段说明:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `total_amount` | string | 该类别在查询时间段内的总金额 |
|
||||||
|
| `entry_count` | integer | 该类别在查询时间段内的记录数 |
|
||||||
|
| `category_key` | string | 类目唯一键,可用于前端做图表的 key 映射 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 快速参考
|
||||||
|
|
||||||
|
| Method | URL | 说明 |
|
||||||
|
|--------|-----|------|
|
||||||
|
| GET | `/api/v2/cost-categories/` | 类目列表 |
|
||||||
|
| POST | `/api/v2/cost-categories/` | 创建类目 |
|
||||||
|
| GET | `/api/v2/cost-categories/{id}/` | 类目详情 |
|
||||||
|
| PUT | `/api/v2/cost-categories/{id}/` | 修改类目 |
|
||||||
|
| DELETE | `/api/v2/cost-categories/{id}/` | 删除类目 |
|
||||||
|
| GET | `/api/v2/cost-entries/` | 支出明细列表 |
|
||||||
|
| POST | `/api/v2/cost-entries/` | 创建支出明细 |
|
||||||
|
| GET | `/api/v2/cost-entries/{id}/` | 支出明细详情 |
|
||||||
|
| PUT | `/api/v2/cost-entries/{id}/` | 修改支出明细 |
|
||||||
|
| DELETE | `/api/v2/cost-entries/{id}/` | 删除支出明细 |
|
||||||
|
| GET | `/api/v2/cost-summary/by-category/` | 按类目汇总 |
|
||||||
180
docs/cost/design.md
Normal file
180
docs/cost/design.md
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
# 成本模块 (cost) 设计文档
|
||||||
|
|
||||||
|
## 1. 概述
|
||||||
|
|
||||||
|
成本模块是 ERP 系统中用于记录和管理各项支出的独立模块。第一版实现手工开支记账功能(支出类目 + 支出明细),后续版本将纳入从其它模块(如印刷 `printing`、库存 `stock`、物流 `shipment` 等)自动采集的成本数据。
|
||||||
|
|
||||||
|
### 设计原则
|
||||||
|
|
||||||
|
- **六边形架构**:通过 `CostProviderPort` 协议定义成本数据输入端口,其它模块只需实现该接口即可被成本模块统一采集。
|
||||||
|
- **双向可依赖**:Provider 既可以被动被 cost 模块调用,也可以主动 `import` cost 模块的基础能力(如 `ensure_category`)来预创建类目。
|
||||||
|
- **独立部署单元**:cost 是独立的 Django app,不修改现有模块。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 模块结构
|
||||||
|
|
||||||
|
```
|
||||||
|
cost/ # 新 Django app
|
||||||
|
├── __init__.py
|
||||||
|
├── apps.py # CostConfig
|
||||||
|
├── models.py # CostCategory, CostEntry, CostProviderPort, CostEntryInput
|
||||||
|
├── services.py # ensure_category, create_cost_entry, aggregate_by_category, collect_from_provider
|
||||||
|
├── admin.py # CostCategoryAdmin, CostEntryAdmin
|
||||||
|
├── tasks.py # Celery 任务(预留)
|
||||||
|
├── tests/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── test_models.py
|
||||||
|
│ └── test_services.py
|
||||||
|
└── migrations/
|
||||||
|
└── __init__.py
|
||||||
|
|
||||||
|
api_v2/views/cost.py # API View(不放在 cost 内部)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 模型设计
|
||||||
|
|
||||||
|
### 3.1 CostCategory — 支出类目
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `id` | BigAutoField (PK) | |
|
||||||
|
| `merchant` | FK → Merchant | 所属商户 |
|
||||||
|
| `unique_key` | CharField (max_length=100) | 全局唯一标识键,用于 Port 协议匹配 |
|
||||||
|
| `name` | CharField (max_length=100) | 类目显示名 |
|
||||||
|
| `parent` | FK → self (null) | 父类目,支持层级 |
|
||||||
|
| `description` | TextField (null) | 备注 |
|
||||||
|
| `created_at` | DateTimeField | ModelBase |
|
||||||
|
| `updated_at` | DateTimeField | ModelBase |
|
||||||
|
|
||||||
|
**约束**:`unique_together = ('merchant', 'unique_key')`
|
||||||
|
|
||||||
|
### 3.2 CostEntry — 支出明细
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `id` | BigAutoField (PK) | |
|
||||||
|
| `merchant` | FK → Merchant | 所属商户 |
|
||||||
|
| `category` | FK → CostCategory | 支出类目 |
|
||||||
|
| `amount` | DecimalField(15, 2) | 金额 |
|
||||||
|
| `occurred_at` | DateField | 发生日期 |
|
||||||
|
| `operator` | FK → Employee | 经办人 |
|
||||||
|
| `image1` | ImageField (null) | 凭证图片 |
|
||||||
|
| `image2` | ImageField (null) | 备用凭证图片 |
|
||||||
|
| `source_module` | CharField (null, max_length=50) | 来源模块名,如 'printing' |
|
||||||
|
| `source_id` | CharField (null, max_length=100) | 来源记录 ID |
|
||||||
|
| `remarks` | TextField (null) | 备注 |
|
||||||
|
| `created_at` | DateTimeField | ModelBase |
|
||||||
|
| `updated_at` | DateTimeField | ModelBase |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 六边形端口协议
|
||||||
|
|
||||||
|
### 4.1 CostEntryInput
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class CostEntryInput:
|
||||||
|
category_key: str # 类目标识键,用于匹配 CostCategory.unique_key
|
||||||
|
category_name: str # 类目显示名(匹配不到时用此名自动创建)
|
||||||
|
amount: Decimal
|
||||||
|
occurred_at: date
|
||||||
|
source_module: str
|
||||||
|
source_id: str
|
||||||
|
remarks: str = ''
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 CostProviderPort (Protocol)
|
||||||
|
|
||||||
|
```python
|
||||||
|
@runtime_checkable
|
||||||
|
class CostProviderPort(Protocol):
|
||||||
|
category_key: str # 类级别:该 Provider 默认使用的类目标识键
|
||||||
|
|
||||||
|
def get_cost_entries(
|
||||||
|
self, *, merchant, start_date: date, end_date: date
|
||||||
|
) -> list[CostEntryInput]:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 双向依赖机制
|
||||||
|
|
||||||
|
**方向 1:Cost 模块调用 Provider**
|
||||||
|
```python
|
||||||
|
# cost/services.py
|
||||||
|
def collect_from_provider(provider: CostProviderPort, *, merchant, start_date, end_date):
|
||||||
|
"""从 Provider 采集成本数据"""
|
||||||
|
for entry in provider.get_cost_entries(merchant=merchant, start_date=start_date, end_date=end_date):
|
||||||
|
cat = ensure_category(merchant=merchant, category_key=entry.category_key, category_name=entry.category_name)
|
||||||
|
create_cost_entry(merchant=merchant, category=cat, ...)
|
||||||
|
```
|
||||||
|
|
||||||
|
**方向 2:Provider 调用 Cost 模块基础能力**
|
||||||
|
```python
|
||||||
|
# 第三方模块中
|
||||||
|
from cost.services import ensure_category
|
||||||
|
|
||||||
|
class PrintingCostProvider:
|
||||||
|
category_key = 'printing_consumables'
|
||||||
|
|
||||||
|
def get_cost_entries(self, *, merchant, start_date, end_date):
|
||||||
|
# Provider 主动确保类目存在
|
||||||
|
ensure_category(merchant=merchant, category_key=self.category_key, category_name='印刷耗材')
|
||||||
|
# ... 计算成本条目 ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 类目匹配逻辑
|
||||||
|
|
||||||
|
`ensure_category()` 优先按 `unique_key` 匹配现有类目,匹配不到时自动创建:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 查 CostCategory.objects.filter(merchant=merchant, unique_key=category_key)
|
||||||
|
2. 命中 → 返回已有类目
|
||||||
|
3. 未命中 → 创建:CostCategory(merchant=merchant, unique_key=category_key, name=category_name)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Services 层
|
||||||
|
|
||||||
|
| 函数 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `ensure_category(*, merchant, category_key, category_name)` | 按 key 查找或创建支出类目 |
|
||||||
|
| `create_cost_entry(*, merchant, category, amount, occurred_at, operator, image1, image2, source_module, source_id, remarks)` | 创建支出记录 |
|
||||||
|
| `collect_from_provider(provider, *, merchant, start_date, end_date)` | 从 Provider 采集成本数据 |
|
||||||
|
| `aggregate_by_category(*, merchant, start_date, end_date)` | 按类别汇总(group by category) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. API 设计 (api_v2)
|
||||||
|
|
||||||
|
| Method | Path | 说明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/api/v2/cost-categories/` | 类目列表(支持 `?merchant_id=` 筛选) |
|
||||||
|
| POST | `/api/v2/cost-categories/` | 创建类目 |
|
||||||
|
| GET | `/api/v2/cost-categories/<id>/` | 类目详情 |
|
||||||
|
| PUT | `/api/v2/cost-categories/<id>/` | 修改类目 |
|
||||||
|
| DELETE | `/api/v2/cost-categories/<id>/` | 删除类目 |
|
||||||
|
| GET | `/api/v2/cost-entries/` | 支出明细列表(支持 `?start=&end=&category_id=&merchant_id=`) |
|
||||||
|
| POST | `/api/v2/cost-entries/` | 创建支出记录(multipart/form-data 支持图片上传) |
|
||||||
|
| GET | `/api/v2/cost-entries/<id>/` | 支出明细详情 |
|
||||||
|
| PUT | `/api/v2/cost-entries/<id>/` | 修改支出记录 |
|
||||||
|
| DELETE | `/api/v2/cost-entries/<id>/` | 删除支出记录 |
|
||||||
|
| GET | `/api/v2/cost-summary/by-category/?start=&end=&merchant_id=` | 按支出类目汇总 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 决策记录
|
||||||
|
|
||||||
|
| # | 决策 | 原因 |
|
||||||
|
|---|------|------|
|
||||||
|
| 1 | 成本模块独立为 `cost` app | 遵循项目惯例 `business`/`printing`/`stock` 各有独立 app;成本后续会关联多模块,独立 app 避免循环依赖 |
|
||||||
|
| 2 | API 放在 `api_v2` 而非 `cost` 内部 | 项目约定 API 层与业务模型分离 |
|
||||||
|
| 3 | 用 `typing.Protocol` 而非 ABC 定义端口 | 不需要显式注册/继承,符合 Python 鸭子类型习惯 |
|
||||||
|
| 4 | `CostCategory.unique_key` 用于 Port 匹配 | 比按 `name` 匹配更稳定,避免重名/改名问题 |
|
||||||
|
| 5 | `CostEntry` 带两个 `ImageField` | 用户要求:一个用于凭证图片,一个预留备用 |
|
||||||
|
| 6 | 汇总 API 按 `start/end` 时间段 + `group by category` | 用户指定的统计方式 |
|
||||||
|
| 7 | 第一版不做 Provider 注册表 | Provider 暂时只有一个调用入口 `collect_from_provider()`,后续可扩展为注册表模式 |
|
||||||
172
docs/cost/inventory-cost-feasibility.md
Normal file
172
docs/cost/inventory-cost-feasibility.md
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
# 库存成本结算可行性分析
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
纺织/印花行业 ERP 中,库存成本核算通常使用两种方法:
|
||||||
|
|
||||||
|
- **先进先出(FIFO)**:先入库的批次先出库,出库成本按对应入库批次的实际采购价计算。
|
||||||
|
- **加权平均**:每次入库后重新计算库存均价(总成本 ÷ 总数量),出库统一按当前均价计算。
|
||||||
|
|
||||||
|
本文档对当前系统的库存模块进行研判,评估是否具备实现这两种成本结算方法的要素。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 当前库存模块数据结构
|
||||||
|
|
||||||
|
### 1.1 核心模型
|
||||||
|
|
||||||
|
```
|
||||||
|
StockChangeRecord(库存变动记录)
|
||||||
|
├── type: 入库/出库
|
||||||
|
├── source_type: 来源类型(采购/销售/调拨/盘盈盘亏/红冲等)
|
||||||
|
├── warehouse: 仓库
|
||||||
|
├── is_finished: 是否已完成
|
||||||
|
└── details ────── StockChangeDetail(库存变动明细)
|
||||||
|
├── product: 产品
|
||||||
|
├── quantity: 数量
|
||||||
|
├── consume_with: 消耗关联(出库指向入库明细,自引用 FK)
|
||||||
|
└── is_consumed: 是否已被消耗
|
||||||
|
|
||||||
|
Inventory(库存汇总)
|
||||||
|
├── product: 产品
|
||||||
|
├── warehouse: 仓库
|
||||||
|
├── quantity: 当前库存数量
|
||||||
|
└── num_of_rolls: 匹数
|
||||||
|
|
||||||
|
StockSnapshot(库存变动快照)
|
||||||
|
├── delta: 变动量
|
||||||
|
├── quantity_before: 变动前库存
|
||||||
|
├── quantity_after: 变动后库存
|
||||||
|
└── (offset/cancelled 红冲链路)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 价格数据所在位置
|
||||||
|
|
||||||
|
价格数据**不在库存模块**,而在业务模块的明细行中:
|
||||||
|
|
||||||
|
| 模型 | 价格字段 | 说明 |
|
||||||
|
|------|---------|------|
|
||||||
|
| `business.PurchaseOrderItem.price` | 采购单价 | 入库成本价的唯一来源 |
|
||||||
|
| `business.SalesOrderItem.price` | 销售单价 | 出库售价,非成本价 |
|
||||||
|
|
||||||
|
### 1.3 关键发现:库存模块不做任何成本核算
|
||||||
|
|
||||||
|
所有 `StockChangeDetail`、`Inventory`、`StockSnapshot` 只记录**数量(quantity)**,完全没有 `cost_price`、`unit_cost`、`total_cost` 等成本维度字段。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 按结算方法逐一分析
|
||||||
|
|
||||||
|
### 2.1 先进先出(FIFO)
|
||||||
|
|
||||||
|
#### 原理
|
||||||
|
|
||||||
|
每次出库时,从最早未消耗的入库批次开始扣除,出库成本等于对应入库批次的实际采购单价。
|
||||||
|
|
||||||
|
#### 已有基础设施 ✅
|
||||||
|
|
||||||
|
`StockChangeDetail.consume_with` 是一个自引用外键,在严进严出模式下,出库明细通过它指向被消耗的入库明细:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# stock/models.py
|
||||||
|
consume_with = models.OneToOneField(
|
||||||
|
'self',
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
null=True, blank=True,
|
||||||
|
related_name='consumed_by_detail',
|
||||||
|
verbose_name='所消耗的入库明细',
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
这是**天然的 FIFO 追踪链**。如果入库明细携带了成本价,出库成本可以直接通过 `consume_with.unit_cost` 确定。
|
||||||
|
|
||||||
|
#### 缺失要素
|
||||||
|
|
||||||
|
| 缺失项 | 说明 |
|
||||||
|
|--------|------|
|
||||||
|
| `StockChangeDetail.unit_cost` | 入库明细需要记录该批次的采购成本单价 |
|
||||||
|
| 宽进宽出模式的批次追踪 | 当前 `consume_with` 仅在严进严出模式下使用,宽进宽出需要补充批次追踪或按 FIFO 规则自动匹配 |
|
||||||
|
|
||||||
|
#### 落地难度:低
|
||||||
|
|
||||||
|
改动范围极小——给 `StockChangeDetail` 加一个 `unit_cost` 字段,在入库时从采购单同步价格,出库成本沿 `consume_with` 链读取即可。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.2 加权平均
|
||||||
|
|
||||||
|
#### 原理
|
||||||
|
|
||||||
|
每次入库后,重新计算加权平均单价:
|
||||||
|
```
|
||||||
|
加权均价 = (库存总成本 + 本次入库成本) ÷ (库存数量 + 本次入库数量)
|
||||||
|
```
|
||||||
|
出库时统一按当前加权均价计算成本。
|
||||||
|
|
||||||
|
#### 已有基础设施 ⚠️
|
||||||
|
|
||||||
|
`Inventory` 表是天然的加权平均计算锚点——它汇总了每个产品在每个仓库的当前库存数量。只需增加一个总成本字段即可完成计算。
|
||||||
|
|
||||||
|
#### 缺失要素
|
||||||
|
|
||||||
|
| 缺失项 | 说明 |
|
||||||
|
|--------|------|
|
||||||
|
| `Inventory.total_cost` | 库存表需要增加总成本字段,每次入库累加 |
|
||||||
|
| `StockChangeDetail.unit_cost` | 同上,入库明细需要知道入库单价 |
|
||||||
|
| 红冲/退货的成本回冲逻辑 | 红冲或退货时需反向调整 `total_cost` 和重新计算均价 |
|
||||||
|
|
||||||
|
#### 落地难度:低
|
||||||
|
|
||||||
|
给 `Inventory` 加一个 `total_cost` 字段,在 `make_stock_change_completed()` 中补充成本累加逻辑即可。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 两种方法对比
|
||||||
|
|
||||||
|
| 维度 | 先进先出 (FIFO) | 加权平均 |
|
||||||
|
|------|:---:|:---:|
|
||||||
|
| 追踪粒度 | 批次级别 | 仓库+产品级别 |
|
||||||
|
| 已有基础设施 | `consume_with` 链 ✅ | `Inventory` 汇总表 ⚠️ |
|
||||||
|
| 需新增字段 | `StockChangeDetail.unit_cost` | `StockChangeDetail.unit_cost` + `Inventory.total_cost` |
|
||||||
|
| 计算复杂度 | 需维护批次消耗顺序 | 每次入库后重新算均价 |
|
||||||
|
| 红冲处理 | 恢复原批次 | 重新计算均价 |
|
||||||
|
| 适用场景 | 价格波动大、需精确追踪每批成本 | 价格稳定、简化核算 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 实施建议
|
||||||
|
|
||||||
|
### 最小改动方案
|
||||||
|
|
||||||
|
两个方法都需要以下共同改动:
|
||||||
|
|
||||||
|
1. **`StockChangeDetail` 增加 `unit_cost` 字段**
|
||||||
|
```python
|
||||||
|
unit_cost = models.DecimalField(max_digits=15, decimal_places=6, null=True, verbose_name='成本单价')
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **入库时填充 `unit_cost`**
|
||||||
|
- 采购入库:从 `PurchaseOrderItem.price` 同步
|
||||||
|
- 销退入库:从原销售出库的成本回冲
|
||||||
|
- 调拨入库:从调出仓的当前成本同步
|
||||||
|
- 盘盈:可设为 0 或要求手动录入
|
||||||
|
|
||||||
|
3. **出库时计算成本**
|
||||||
|
- FIFO:沿 `consume_with` 链读取对应入库批次的 `unit_cost`
|
||||||
|
- 加权平均:需额外在 `Inventory` 表增加 `total_cost` 字段,在 `make_stock_change_completed` 中维护
|
||||||
|
|
||||||
|
### 建议优先实现 FIFO
|
||||||
|
|
||||||
|
对于纺织行业(布料批次间价格差异大),**FIFO 更合适**。且当前 `consume_with` 链已就绪,实现成本最低。
|
||||||
|
|
||||||
|
若后续需要加权平均,在 FIFO 的基础上给 `Inventory` 增加 `total_cost` 即可,两者不冲突。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 结论
|
||||||
|
|
||||||
|
**当前系统不具备直接进行先进先出或加权平均成本结算的能力**,库存模块完全是数量管理。
|
||||||
|
|
||||||
|
但 **FIFO 所需的基础设施已经存在**(`consume_with` 追踪链),改动范围极小——仅需给 `StockChangeDetail` 增加 `unit_cost` 字段,并在入库时同步采购价格。加权平均也只需在 `Inventory` 表增加 `total_cost` 字段即可。
|
||||||
|
|
||||||
|
两个方法的落地成本都很低,不存在结构性障碍。
|
||||||
@@ -102,11 +102,11 @@ HAOBUYE_FINANCE_SYNC_OPERATOR_ID = env.int('HAOBUYE_FINANCE_SYNC_OPERATOR_ID', d
|
|||||||
|
|
||||||
# 定时财务同步客户列表(临时需求,直接写死不走 env)
|
# 定时财务同步客户列表(临时需求,直接写死不走 env)
|
||||||
FINANCE_SYNC_CUSTOMER_NAMES: list[str] = [
|
FINANCE_SYNC_CUSTOMER_NAMES: list[str] = [
|
||||||
'曾念', '紫琪', '胡肖宇', '歌斯拉-胜利星厂', '胡鼎',
|
# '曾念', '紫琪', '胡肖宇', '歌斯拉-胜利星厂', '胡鼎',
|
||||||
'罗标', '永琪服饰', '罗兵', '杜辉', '李群', '超麦汇大洋',
|
# '罗标', '永琪服饰', '罗兵', '杜辉', '李群', '超麦汇大洋',
|
||||||
'展兴', '王杰敏', '锐木', '何玄', '周刚(周总)', '彤彤服饰',
|
# '展兴', '王杰敏', '锐木', '何玄', '周刚(周总)', '彤彤服饰',
|
||||||
'杰恩', '来发裁床', '刘静贤', '金源', '达达-金腾达qs', '周乔峰',
|
# '杰恩', '来发裁床', '刘静贤', '金源', '达达-金腾达qs', '周乔峰',
|
||||||
'杨锐辉',
|
'杨锐辉', '杨赛英', '别坤', 'A黄宇', '英豪'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -169,6 +169,7 @@ INSTALLED_APPS = [
|
|||||||
'mission',
|
'mission',
|
||||||
'notifier',
|
'notifier',
|
||||||
'mes',
|
'mes',
|
||||||
|
'cost',
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
|||||||
Reference in New Issue
Block a user