forked from erp-dev/erp
feat: big
This commit is contained in:
@@ -206,6 +206,32 @@ class CostAPITest(TestCase):
|
|||||||
self.assertEqual(resp.data['category_name'], '电费')
|
self.assertEqual(resp.data['category_name'], '电费')
|
||||||
self.assertEqual(resp.data['operator_id'], self.employee.id)
|
self.assertEqual(resp.data['operator_id'], self.employee.id)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_formula_entry(self):
|
||||||
|
cat = self._create_category('temp_worker', '临时工工资')
|
||||||
|
resp = self.client.post('/api/v2/cost-entries/', {
|
||||||
|
'category_id': cat.id,
|
||||||
|
'unit_amount': '200.00',
|
||||||
|
'quantity': '3',
|
||||||
|
'unit_name': '人天',
|
||||||
|
'occurred_at': '2026-06-01',
|
||||||
|
'remarks': '临时工 3 人天',
|
||||||
|
}, format='json')
|
||||||
|
self.assertEqual(resp.status_code, 201)
|
||||||
|
self.assertEqual(resp.data['amount'], '600.00')
|
||||||
|
self.assertEqual(resp.data['unit_amount'], '200.0000')
|
||||||
|
self.assertEqual(resp.data['quantity'], '3.0000')
|
||||||
|
self.assertEqual(resp.data['unit_name'], '人天')
|
||||||
|
|
||||||
|
def test_create_formula_entry_partial_fields_returns_400(self):
|
||||||
|
cat = self._create_category('temp_worker', '临时工工资')
|
||||||
|
resp = self.client.post('/api/v2/cost-entries/', {
|
||||||
|
'category_id': cat.id,
|
||||||
|
'unit_amount': '200.00',
|
||||||
|
'occurred_at': '2026-06-01',
|
||||||
|
}, format='json')
|
||||||
|
self.assertEqual(resp.status_code, 400)
|
||||||
|
|
||||||
def test_create_entry_category_not_found(self):
|
def test_create_entry_category_not_found(self):
|
||||||
resp = self.client.post('/api/v2/cost-entries/', {
|
resp = self.client.post('/api/v2/cost-entries/', {
|
||||||
'category_id': 99999,
|
'category_id': 99999,
|
||||||
@@ -275,6 +301,41 @@ class CostAPITest(TestCase):
|
|||||||
self.assertEqual(resp.data['amount'], '999.00')
|
self.assertEqual(resp.data['amount'], '999.00')
|
||||||
self.assertEqual(resp.data['remarks'], '已修改')
|
self.assertEqual(resp.data['remarks'], '已修改')
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_formula_entry_recalculates_amount(self):
|
||||||
|
cat = self._create_category('temp_worker', '临时工工资')
|
||||||
|
entry = cost_models.CostEntry.objects.create(
|
||||||
|
merchant=self.merchant, category=cat,
|
||||||
|
amount=None, unit_amount=Decimal('200.00'), quantity=Decimal('3'),
|
||||||
|
unit_name='人天', occurred_at=date(2026, 6, 1),
|
||||||
|
)
|
||||||
|
resp = self.client.put(f'/api/v2/cost-entries/{entry.id}/', {
|
||||||
|
'quantity': '4',
|
||||||
|
}, format='json')
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
self.assertEqual(resp.data['amount'], '800.00')
|
||||||
|
self.assertEqual(resp.data['quantity'], '4.0000')
|
||||||
|
entry.refresh_from_db()
|
||||||
|
self.assertEqual(entry.amount, Decimal('800.00'))
|
||||||
|
|
||||||
|
def test_update_formula_entry_to_manual_amount(self):
|
||||||
|
cat = self._create_category('temp_worker', '临时工工资')
|
||||||
|
entry = cost_models.CostEntry.objects.create(
|
||||||
|
merchant=self.merchant, category=cat,
|
||||||
|
amount=None, unit_amount=Decimal('200.00'), quantity=Decimal('3'),
|
||||||
|
unit_name='人天', occurred_at=date(2026, 6, 1),
|
||||||
|
)
|
||||||
|
resp = self.client.put(f'/api/v2/cost-entries/{entry.id}/', {
|
||||||
|
'amount': '550.00',
|
||||||
|
'unit_amount': None,
|
||||||
|
'quantity': None,
|
||||||
|
'unit_name': '',
|
||||||
|
}, format='json')
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
self.assertEqual(resp.data['amount'], '550.00')
|
||||||
|
self.assertIsNone(resp.data['unit_amount'])
|
||||||
|
self.assertIsNone(resp.data['quantity'])
|
||||||
|
|
||||||
def test_update_entry_change_category(self):
|
def test_update_entry_change_category(self):
|
||||||
cat1 = self._create_category('elec', '电费')
|
cat1 = self._create_category('elec', '电费')
|
||||||
cat2 = self._create_category('water', '水费')
|
cat2 = self._create_category('water', '水费')
|
||||||
|
|||||||
@@ -542,29 +542,33 @@ class MissionV2APITest(TestCase):
|
|||||||
["通用", "跟进"],
|
["通用", "跟进"],
|
||||||
)
|
)
|
||||||
self.assertEqual(list_resp.data[0]["payload_processor"], "")
|
self.assertEqual(list_resp.data[0]["payload_processor"], "")
|
||||||
|
self.assertFalse(list_resp.data[0]["speech_enabled"])
|
||||||
|
|
||||||
create_resp = self.client.post(
|
create_resp = self.client.post(
|
||||||
"/api/v2/mission-categories/",
|
"/api/v2/mission-categories/",
|
||||||
{"name": "售后", "payload_processor": "structured_description_v1"},
|
{"name": "售后", "payload_processor": "structured_description_v1", "speech_enabled": True},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
self.assertEqual(create_resp.status_code, 201)
|
self.assertEqual(create_resp.status_code, 201)
|
||||||
category_id = create_resp.data["id"]
|
category_id = create_resp.data["id"]
|
||||||
self.assertEqual(create_resp.data["payload_processor"], "structured_description_v1")
|
self.assertEqual(create_resp.data["payload_processor"], "structured_description_v1")
|
||||||
|
self.assertTrue(create_resp.data["speech_enabled"])
|
||||||
|
|
||||||
detail_resp = self.client.get(f"/api/v2/mission-categories/{category_id}/")
|
detail_resp = self.client.get(f"/api/v2/mission-categories/{category_id}/")
|
||||||
self.assertEqual(detail_resp.status_code, 200)
|
self.assertEqual(detail_resp.status_code, 200)
|
||||||
self.assertEqual(detail_resp.data["name"], "售后")
|
self.assertEqual(detail_resp.data["name"], "售后")
|
||||||
self.assertEqual(detail_resp.data["payload_processor"], "structured_description_v1")
|
self.assertEqual(detail_resp.data["payload_processor"], "structured_description_v1")
|
||||||
|
self.assertTrue(detail_resp.data["speech_enabled"])
|
||||||
|
|
||||||
patch_resp = self.client.patch(
|
patch_resp = self.client.patch(
|
||||||
f"/api/v2/mission-categories/{category_id}/",
|
f"/api/v2/mission-categories/{category_id}/",
|
||||||
{"name": "售后跟进", "payload_processor": ""},
|
{"name": "售后跟进", "payload_processor": "", "speech_enabled": False},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
self.assertEqual(patch_resp.status_code, 200)
|
self.assertEqual(patch_resp.status_code, 200)
|
||||||
self.assertEqual(patch_resp.data["name"], "售后跟进")
|
self.assertEqual(patch_resp.data["name"], "售后跟进")
|
||||||
self.assertEqual(patch_resp.data["payload_processor"], "")
|
self.assertEqual(patch_resp.data["payload_processor"], "")
|
||||||
|
self.assertFalse(patch_resp.data["speech_enabled"])
|
||||||
|
|
||||||
delete_resp = self.client.delete(f"/api/v2/mission-categories/{category_id}/")
|
delete_resp = self.client.delete(f"/api/v2/mission-categories/{category_id}/")
|
||||||
self.assertEqual(delete_resp.status_code, 204)
|
self.assertEqual(delete_resp.status_code, 204)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
|
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from rest_framework import permissions, serializers, status
|
from rest_framework import permissions, serializers, status
|
||||||
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
|
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
|
||||||
@@ -38,6 +39,9 @@ def _entry_payload(entry: cost_models.CostEntry) -> dict:
|
|||||||
'category_id': entry.category_id,
|
'category_id': entry.category_id,
|
||||||
'category_name': entry.category.name if entry.category_id else '',
|
'category_name': entry.category.name if entry.category_id else '',
|
||||||
'amount': str(entry.amount),
|
'amount': str(entry.amount),
|
||||||
|
'unit_amount': str(entry.unit_amount) if entry.unit_amount is not None else None,
|
||||||
|
'quantity': str(entry.quantity) if entry.quantity is not None else None,
|
||||||
|
'unit_name': entry.unit_name or '',
|
||||||
'occurred_at': entry.occurred_at.isoformat(),
|
'occurred_at': entry.occurred_at.isoformat(),
|
||||||
'operator_id': entry.operator_id,
|
'operator_id': entry.operator_id,
|
||||||
'image1': _image_url(entry.image1),
|
'image1': _image_url(entry.image1),
|
||||||
@@ -81,7 +85,16 @@ class CategoryUpdateSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
class EntryWriteSerializer(serializers.Serializer):
|
class EntryWriteSerializer(serializers.Serializer):
|
||||||
category_id = serializers.IntegerField(min_value=1)
|
category_id = serializers.IntegerField(min_value=1)
|
||||||
amount = serializers.DecimalField(max_digits=15, decimal_places=2, min_value=0)
|
amount = serializers.DecimalField(
|
||||||
|
required=False, allow_null=True, max_digits=15, decimal_places=2, min_value=0,
|
||||||
|
)
|
||||||
|
unit_amount = serializers.DecimalField(
|
||||||
|
required=False, allow_null=True, max_digits=15, decimal_places=4, min_value=0,
|
||||||
|
)
|
||||||
|
quantity = serializers.DecimalField(
|
||||||
|
required=False, allow_null=True, max_digits=12, decimal_places=4, min_value=0,
|
||||||
|
)
|
||||||
|
unit_name = serializers.CharField(required=False, allow_blank=True, max_length=20, default='')
|
||||||
occurred_at = serializers.DateField()
|
occurred_at = serializers.DateField()
|
||||||
image1 = serializers.ImageField(required=False, allow_null=True)
|
image1 = serializers.ImageField(required=False, allow_null=True)
|
||||||
image2 = serializers.ImageField(required=False, allow_null=True)
|
image2 = serializers.ImageField(required=False, allow_null=True)
|
||||||
@@ -89,10 +102,30 @@ class EntryWriteSerializer(serializers.Serializer):
|
|||||||
source_id = 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='')
|
remarks = serializers.CharField(required=False, allow_blank=True, default='')
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
unit_amount = attrs.get('unit_amount')
|
||||||
|
quantity = attrs.get('quantity')
|
||||||
|
has_unit_amount = unit_amount is not None
|
||||||
|
has_quantity = quantity is not None
|
||||||
|
if has_unit_amount != has_quantity:
|
||||||
|
raise serializers.ValidationError('unit_amount 和 quantity 必须同时填写或同时为空')
|
||||||
|
if not has_unit_amount and attrs.get('amount') is None:
|
||||||
|
raise serializers.ValidationError('普通支出必须填写 amount;倍数型支出必须填写 unit_amount 和 quantity')
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
class EntryUpdateSerializer(serializers.Serializer):
|
class EntryUpdateSerializer(serializers.Serializer):
|
||||||
category_id = serializers.IntegerField(required=False, min_value=1)
|
category_id = serializers.IntegerField(required=False, min_value=1)
|
||||||
amount = serializers.DecimalField(required=False, max_digits=15, decimal_places=2, min_value=0)
|
amount = serializers.DecimalField(
|
||||||
|
required=False, allow_null=True, max_digits=15, decimal_places=2, min_value=0,
|
||||||
|
)
|
||||||
|
unit_amount = serializers.DecimalField(
|
||||||
|
required=False, allow_null=True, max_digits=15, decimal_places=4, min_value=0,
|
||||||
|
)
|
||||||
|
quantity = serializers.DecimalField(
|
||||||
|
required=False, allow_null=True, max_digits=12, decimal_places=4, min_value=0,
|
||||||
|
)
|
||||||
|
unit_name = serializers.CharField(required=False, allow_blank=True, max_length=20)
|
||||||
occurred_at = serializers.DateField(required=False)
|
occurred_at = serializers.DateField(required=False)
|
||||||
image1 = serializers.ImageField(required=False, allow_null=True)
|
image1 = serializers.ImageField(required=False, allow_null=True)
|
||||||
image2 = serializers.ImageField(required=False, allow_null=True)
|
image2 = serializers.ImageField(required=False, allow_null=True)
|
||||||
@@ -210,11 +243,15 @@ class CostEntryListCreateView(APIView):
|
|||||||
merchant_id=employee.merchant_id,
|
merchant_id=employee.merchant_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
entry = cost_models.CostEntry.objects.create(
|
try:
|
||||||
|
entry = cost_services.create_cost_entry(
|
||||||
merchant=employee.merchant,
|
merchant=employee.merchant,
|
||||||
category=category,
|
category=category,
|
||||||
amount=data['amount'],
|
|
||||||
occurred_at=data['occurred_at'],
|
occurred_at=data['occurred_at'],
|
||||||
|
amount=data.get('amount'),
|
||||||
|
unit_amount=data.get('unit_amount'),
|
||||||
|
quantity=data.get('quantity'),
|
||||||
|
unit_name=data.get('unit_name', ''),
|
||||||
operator=employee,
|
operator=employee,
|
||||||
image1=data.get('image1'),
|
image1=data.get('image1'),
|
||||||
image2=data.get('image2'),
|
image2=data.get('image2'),
|
||||||
@@ -222,6 +259,8 @@ class CostEntryListCreateView(APIView):
|
|||||||
source_id=data.get('source_id', ''),
|
source_id=data.get('source_id', ''),
|
||||||
remarks=data.get('remarks', ''),
|
remarks=data.get('remarks', ''),
|
||||||
)
|
)
|
||||||
|
except DjangoValidationError as exc:
|
||||||
|
raise serializers.ValidationError(exc.message_dict if hasattr(exc, 'message_dict') else exc.messages)
|
||||||
return Response(_entry_payload(entry), status=status.HTTP_201_CREATED)
|
return Response(_entry_payload(entry), status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
|
||||||
@@ -261,7 +300,10 @@ class CostEntryDetailView(APIView):
|
|||||||
setattr(entry, field, value)
|
setattr(entry, field, value)
|
||||||
update_fields.append(field)
|
update_fields.append(field)
|
||||||
if update_fields:
|
if update_fields:
|
||||||
|
try:
|
||||||
entry.save(update_fields=update_fields)
|
entry.save(update_fields=update_fields)
|
||||||
|
except DjangoValidationError as exc:
|
||||||
|
raise serializers.ValidationError(exc.message_dict if hasattr(exc, 'message_dict') else exc.messages)
|
||||||
entry.refresh_from_db()
|
entry.refresh_from_db()
|
||||||
return Response(_entry_payload(entry))
|
return Response(_entry_payload(entry))
|
||||||
|
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ class MissionUrgentSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
class MissionCategoryWriteSerializer(serializers.Serializer):
|
class MissionCategoryWriteSerializer(serializers.Serializer):
|
||||||
name = serializers.CharField(allow_blank=False, max_length=50)
|
name = serializers.CharField(allow_blank=False, max_length=50)
|
||||||
|
speech_enabled = serializers.BooleanField(required=False)
|
||||||
payload_processor = serializers.ChoiceField(
|
payload_processor = serializers.ChoiceField(
|
||||||
choices=mission_models.MissionPayloadProcessorEnum.choices,
|
choices=mission_models.MissionPayloadProcessorEnum.choices,
|
||||||
required=False,
|
required=False,
|
||||||
@@ -257,6 +258,7 @@ class MissionCategorySerializer(serializers.ModelSerializer):
|
|||||||
"merchant",
|
"merchant",
|
||||||
"name",
|
"name",
|
||||||
"payload_processor",
|
"payload_processor",
|
||||||
|
"speech_enabled",
|
||||||
"created_at",
|
"created_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
]
|
]
|
||||||
@@ -409,6 +411,7 @@ class MissionCategoryListCreateView(APIView):
|
|||||||
merchant=employee.merchant,
|
merchant=employee.merchant,
|
||||||
name=serializer.validated_data["name"],
|
name=serializer.validated_data["name"],
|
||||||
payload_processor=serializer.validated_data.get("payload_processor", ""),
|
payload_processor=serializer.validated_data.get("payload_processor", ""),
|
||||||
|
speech_enabled=serializer.validated_data.get("speech_enabled", False),
|
||||||
)
|
)
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
return Response({"name": ["分类名称已存在"]}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({"name": ["分类名称已存在"]}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
@@ -442,6 +445,9 @@ class MissionCategoryDetailView(APIView):
|
|||||||
if "payload_processor" in serializer.validated_data:
|
if "payload_processor" in serializer.validated_data:
|
||||||
category.payload_processor = serializer.validated_data["payload_processor"]
|
category.payload_processor = serializer.validated_data["payload_processor"]
|
||||||
update_fields.append("payload_processor")
|
update_fields.append("payload_processor")
|
||||||
|
if "speech_enabled" in serializer.validated_data:
|
||||||
|
category.speech_enabled = serializer.validated_data["speech_enabled"]
|
||||||
|
update_fields.append("speech_enabled")
|
||||||
if update_fields:
|
if update_fields:
|
||||||
try:
|
try:
|
||||||
category.save(update_fields=[*update_fields, "updated_at"])
|
category.save(update_fields=[*update_fields, "updated_at"])
|
||||||
|
|||||||
40
business/notifications.py
Normal file
40
business/notifications.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue_business_order_speech(*, order, order_label: str, action_label: str) -> None:
|
||||||
|
if not getattr(settings, 'BUSINESS_ORDER_SPEECH_NOTIFY_ENABLED', True):
|
||||||
|
return
|
||||||
|
if _is_external_order(order):
|
||||||
|
return
|
||||||
|
|
||||||
|
order_id = _get_order_identifier(order)
|
||||||
|
text = f'{order_label} {order_id} 已{action_label}'
|
||||||
|
|
||||||
|
def _enqueue():
|
||||||
|
try:
|
||||||
|
from business.tasks import notify_business_order_speech
|
||||||
|
|
||||||
|
notify_business_order_speech.delay(text=text)
|
||||||
|
except Exception:
|
||||||
|
logger.exception('[business.notifications] 投递业务单据语音播报任务失败(已忽略)')
|
||||||
|
|
||||||
|
transaction.on_commit(_enqueue)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_external_order(order) -> bool:
|
||||||
|
return bool(
|
||||||
|
getattr(order, 'is_external_source', False)
|
||||||
|
or getattr(order, 'external_source_id', None)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_order_identifier(order) -> str:
|
||||||
|
human_id = getattr(order, 'human_id', None)
|
||||||
|
if human_id:
|
||||||
|
return str(human_id)
|
||||||
|
return str(getattr(order, 'id', ''))
|
||||||
@@ -13,6 +13,7 @@ from stock import models as stock_models
|
|||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
from . import services as business_services
|
from . import services as business_services
|
||||||
|
from .notifications import enqueue_business_order_speech
|
||||||
|
|
||||||
|
|
||||||
def render_pre_sales_order_created_markdown(
|
def render_pre_sales_order_created_markdown(
|
||||||
@@ -304,6 +305,7 @@ def create_pre_sales_order(
|
|||||||
transaction.on_commit(_send_created_signal)
|
transaction.on_commit(_send_created_signal)
|
||||||
|
|
||||||
pre_sales_order.refresh_from_db()
|
pre_sales_order.refresh_from_db()
|
||||||
|
enqueue_business_order_speech(order=pre_sales_order, order_label='预销售单', action_label='创建')
|
||||||
return pre_sales_order
|
return pre_sales_order
|
||||||
|
|
||||||
|
|
||||||
@@ -580,6 +582,7 @@ def create_pre_purchase_order(
|
|||||||
)
|
)
|
||||||
|
|
||||||
pre_purchase_order.refresh_from_db()
|
pre_purchase_order.refresh_from_db()
|
||||||
|
enqueue_business_order_speech(order=pre_purchase_order, order_label='预采购单', action_label='创建')
|
||||||
return pre_purchase_order
|
return pre_purchase_order
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ from .tasks import (
|
|||||||
create_purchase_return_order_stock_entries,
|
create_purchase_return_order_stock_entries,
|
||||||
create_sales_return_order_stock_entries,
|
create_sales_return_order_stock_entries,
|
||||||
)
|
)
|
||||||
|
from .notifications import enqueue_business_order_speech
|
||||||
|
|
||||||
|
|
||||||
class BalanceService:
|
class BalanceService:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def adjust_supplier_balance(
|
def adjust_supplier_balance(
|
||||||
@@ -317,6 +320,7 @@ def create_purchase_order(
|
|||||||
models.PurchaseOrderItem.objects.bulk_create(bulk_objects)
|
models.PurchaseOrderItem.objects.bulk_create(bulk_objects)
|
||||||
|
|
||||||
purchase_order.refresh_from_db()
|
purchase_order.refresh_from_db()
|
||||||
|
enqueue_business_order_speech(order=purchase_order, order_label='采购单', action_label='创建')
|
||||||
return purchase_order
|
return purchase_order
|
||||||
|
|
||||||
|
|
||||||
@@ -462,6 +466,7 @@ def create_sales_order(
|
|||||||
models.SalesOrderItem.objects.bulk_create(bulk_objects)
|
models.SalesOrderItem.objects.bulk_create(bulk_objects)
|
||||||
|
|
||||||
sales_order.refresh_from_db()
|
sales_order.refresh_from_db()
|
||||||
|
enqueue_business_order_speech(order=sales_order, order_label='销售单', action_label='创建')
|
||||||
return sales_order
|
return sales_order
|
||||||
|
|
||||||
|
|
||||||
@@ -613,6 +618,7 @@ def create_purchase_return_order(
|
|||||||
models.PurchaseReturnOrderItem.objects.bulk_create(bulk_objects)
|
models.PurchaseReturnOrderItem.objects.bulk_create(bulk_objects)
|
||||||
|
|
||||||
return_order.refresh_from_db()
|
return_order.refresh_from_db()
|
||||||
|
enqueue_business_order_speech(order=return_order, order_label='采购退货单', action_label='创建')
|
||||||
return return_order
|
return return_order
|
||||||
|
|
||||||
|
|
||||||
@@ -766,6 +772,7 @@ def create_sales_return_order(
|
|||||||
models.SalesReturnOrderItem.objects.bulk_create(bulk_objects)
|
models.SalesReturnOrderItem.objects.bulk_create(bulk_objects)
|
||||||
|
|
||||||
return_order.refresh_from_db()
|
return_order.refresh_from_db()
|
||||||
|
enqueue_business_order_speech(order=return_order, order_label='销售退货单', action_label='创建')
|
||||||
return return_order
|
return return_order
|
||||||
|
|
||||||
|
|
||||||
@@ -883,6 +890,7 @@ def create_payment_order(
|
|||||||
discount_amount=normalized_discount,
|
discount_amount=normalized_discount,
|
||||||
)
|
)
|
||||||
payment_order.refresh_from_db()
|
payment_order.refresh_from_db()
|
||||||
|
enqueue_business_order_speech(order=payment_order, order_label='付款单', action_label='创建')
|
||||||
return payment_order
|
return payment_order
|
||||||
|
|
||||||
|
|
||||||
@@ -921,6 +929,7 @@ def create_receipt_order(
|
|||||||
discount_amount=normalized_discount,
|
discount_amount=normalized_discount,
|
||||||
)
|
)
|
||||||
receipt_order.refresh_from_db()
|
receipt_order.refresh_from_db()
|
||||||
|
enqueue_business_order_speech(order=receipt_order, order_label='收款单', action_label='创建')
|
||||||
return receipt_order
|
return receipt_order
|
||||||
|
|
||||||
|
|
||||||
@@ -1687,6 +1696,7 @@ def _cancel_order_impl(
|
|||||||
locked.status = cancelled_status
|
locked.status = cancelled_status
|
||||||
locked.save(update_fields=['status', 'updated_at'])
|
locked.save(update_fields=['status', 'updated_at'])
|
||||||
locked.refresh_from_db(fields=['status', 'updated_at'])
|
locked.refresh_from_db(fields=['status', 'updated_at'])
|
||||||
|
enqueue_business_order_speech(order=locked, order_label=error_label, action_label='作废')
|
||||||
return locked
|
return locked
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -157,3 +157,24 @@ def create_sales_return_order_stock_entries(
|
|||||||
payload['task_id'] = self.request.id
|
payload['task_id'] = self.request.id
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(bind=True)
|
||||||
|
def notify_business_order_speech(self, *, text: str) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
from flower.utils import play_speech
|
||||||
|
|
||||||
|
response = play_speech(text=text, timeout_seconds=3.0)
|
||||||
|
return {
|
||||||
|
'status': 'sent',
|
||||||
|
'task_id': self.request.id,
|
||||||
|
'status_code': response.status_code,
|
||||||
|
'raw': response.raw,
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception('[business.tasks] 业务单据语音播报失败(已忽略)')
|
||||||
|
return {
|
||||||
|
'status': 'error',
|
||||||
|
'task_id': self.request.id,
|
||||||
|
'error': str(exc),
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ class CostCategoryAdmin(admin.ModelAdmin):
|
|||||||
@admin.register(models.CostEntry)
|
@admin.register(models.CostEntry)
|
||||||
class CostEntryAdmin(admin.ModelAdmin):
|
class CostEntryAdmin(admin.ModelAdmin):
|
||||||
list_display = (
|
list_display = (
|
||||||
'id', 'merchant', 'category', 'amount', 'occurred_at',
|
'id', 'merchant', 'category', 'amount', 'unit_amount', 'quantity',
|
||||||
'operator', 'source_module', 'source_id', 'created_at',
|
'unit_name', 'occurred_at', 'operator', 'source_module', 'source_id',
|
||||||
|
'created_at',
|
||||||
)
|
)
|
||||||
search_fields = ('category__name', 'source_module', 'source_id')
|
search_fields = ('category__name', 'source_module', 'source_id')
|
||||||
list_filter = ('merchant', 'category', 'occurred_at', 'source_module', 'created_at')
|
list_filter = ('merchant', 'category', 'occurred_at', 'source_module', 'created_at')
|
||||||
|
|||||||
33
cost/migrations/0002_costentry_amount_formula_fields.py
Normal file
33
cost/migrations/0002_costentry_amount_formula_fields.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Codex on 2026-06-30
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('cost', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='costentry',
|
||||||
|
name='amount',
|
||||||
|
field=models.DecimalField(blank=True, decimal_places=2, max_digits=15, verbose_name='金额'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='costentry',
|
||||||
|
name='unit_amount',
|
||||||
|
field=models.DecimalField(blank=True, decimal_places=4, max_digits=15, null=True, verbose_name='单价/基数金额'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='costentry',
|
||||||
|
name='quantity',
|
||||||
|
field=models.DecimalField(blank=True, decimal_places=4, max_digits=12, null=True, verbose_name='数量/倍数'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='costentry',
|
||||||
|
name='unit_name',
|
||||||
|
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='单位名称'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -2,9 +2,10 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from decimal import Decimal
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
from typing import Protocol, runtime_checkable
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
from flower.common import ModelBase
|
from flower.common import ModelBase
|
||||||
@@ -44,6 +45,8 @@ class CostCategory(ModelBase):
|
|||||||
class CostEntry(ModelBase):
|
class CostEntry(ModelBase):
|
||||||
"""支出明细"""
|
"""支出明细"""
|
||||||
|
|
||||||
|
AMOUNT_QUANT = Decimal('0.01')
|
||||||
|
|
||||||
merchant = models.ForeignKey(
|
merchant = models.ForeignKey(
|
||||||
'basic_info.Merchant',
|
'basic_info.Merchant',
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
@@ -56,7 +59,27 @@ class CostEntry(ModelBase):
|
|||||||
related_name='entries',
|
related_name='entries',
|
||||||
verbose_name='支出类目',
|
verbose_name='支出类目',
|
||||||
)
|
)
|
||||||
amount = models.DecimalField(max_digits=15, decimal_places=2, verbose_name='金额')
|
amount = models.DecimalField(max_digits=15, decimal_places=2, blank=True, verbose_name='金额')
|
||||||
|
unit_amount = models.DecimalField(
|
||||||
|
max_digits=15,
|
||||||
|
decimal_places=4,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='单价/基数金额',
|
||||||
|
)
|
||||||
|
quantity = models.DecimalField(
|
||||||
|
max_digits=12,
|
||||||
|
decimal_places=4,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='数量/倍数',
|
||||||
|
)
|
||||||
|
unit_name = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='单位名称',
|
||||||
|
)
|
||||||
occurred_at = models.DateField(verbose_name='发生日期')
|
occurred_at = models.DateField(verbose_name='发生日期')
|
||||||
operator = models.ForeignKey(
|
operator = models.ForeignKey(
|
||||||
'basic_info.Employee',
|
'basic_info.Employee',
|
||||||
@@ -84,6 +107,41 @@ class CostEntry(ModelBase):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f'{self.category.name} {self.amount} ({self.occurred_at})'
|
return f'{self.category.name} {self.amount} ({self.occurred_at})'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_amount_formula(self) -> bool:
|
||||||
|
"""是否使用 unit_amount * quantity 推导最终金额。"""
|
||||||
|
return self.unit_amount is not None or self.quantity is not None
|
||||||
|
|
||||||
|
def calculate_amount(self) -> Decimal | None:
|
||||||
|
"""根据单价和数量计算最终金额。"""
|
||||||
|
if self.unit_amount is None and self.quantity is None:
|
||||||
|
return None
|
||||||
|
if self.unit_amount is None or self.quantity is None:
|
||||||
|
raise ValidationError({
|
||||||
|
'unit_amount': 'unit_amount 和 quantity 必须同时填写或同时为空',
|
||||||
|
'quantity': 'unit_amount 和 quantity 必须同时填写或同时为空',
|
||||||
|
})
|
||||||
|
return (self.unit_amount * self.quantity).quantize(self.AMOUNT_QUANT, rounding=ROUND_HALF_UP)
|
||||||
|
|
||||||
|
def apply_amount_formula(self) -> None:
|
||||||
|
"""如果存在公式字段,则用公式结果覆盖 amount。"""
|
||||||
|
calculated_amount = self.calculate_amount()
|
||||||
|
if calculated_amount is not None:
|
||||||
|
self.amount = calculated_amount
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
super().clean()
|
||||||
|
self.apply_amount_formula()
|
||||||
|
if self.amount is None:
|
||||||
|
raise ValidationError({'amount': '普通支出必须填写 amount'})
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
self.clean()
|
||||||
|
update_fields = kwargs.get('update_fields')
|
||||||
|
if update_fields is not None and self.has_amount_formula:
|
||||||
|
kwargs['update_fields'] = set(update_fields) | {'amount'}
|
||||||
|
return super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
# ==================== 六边形端口协议 ====================
|
# ==================== 六边形端口协议 ====================
|
||||||
|
|
||||||
@@ -94,10 +152,13 @@ class CostEntryInput:
|
|||||||
|
|
||||||
category_key: str
|
category_key: str
|
||||||
category_name: str
|
category_name: str
|
||||||
amount: Decimal
|
amount: Decimal | None
|
||||||
occurred_at: date
|
occurred_at: date
|
||||||
source_module: str
|
source_module: str
|
||||||
source_id: str
|
source_id: str
|
||||||
|
unit_amount: Decimal | None = None
|
||||||
|
quantity: Decimal | None = None
|
||||||
|
unit_name: str = ''
|
||||||
remarks: str = ''
|
remarks: str = ''
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -51,11 +51,14 @@ def create_cost_entry(
|
|||||||
*,
|
*,
|
||||||
merchant: Merchant,
|
merchant: Merchant,
|
||||||
category: cost_models.CostCategory,
|
category: cost_models.CostCategory,
|
||||||
amount: Decimal,
|
|
||||||
occurred_at: date,
|
occurred_at: date,
|
||||||
|
amount: Decimal | None = None,
|
||||||
operator: Employee | None = None,
|
operator: Employee | None = None,
|
||||||
image1=None,
|
image1=None,
|
||||||
image2=None,
|
image2=None,
|
||||||
|
unit_amount: Decimal | None = None,
|
||||||
|
quantity: Decimal | None = None,
|
||||||
|
unit_name: str = '',
|
||||||
source_module: str = '',
|
source_module: str = '',
|
||||||
source_id: str = '',
|
source_id: str = '',
|
||||||
remarks: str = '',
|
remarks: str = '',
|
||||||
@@ -70,6 +73,9 @@ def create_cost_entry(
|
|||||||
merchant=merchant,
|
merchant=merchant,
|
||||||
category=category,
|
category=category,
|
||||||
amount=amount,
|
amount=amount,
|
||||||
|
unit_amount=unit_amount,
|
||||||
|
quantity=quantity,
|
||||||
|
unit_name=unit_name or '',
|
||||||
occurred_at=occurred_at,
|
occurred_at=occurred_at,
|
||||||
operator=operator,
|
operator=operator,
|
||||||
image1=image1,
|
image1=image1,
|
||||||
@@ -122,6 +128,9 @@ def collect_from_provider(
|
|||||||
merchant=merchant,
|
merchant=merchant,
|
||||||
category=category,
|
category=category,
|
||||||
amount=entry.amount,
|
amount=entry.amount,
|
||||||
|
unit_amount=entry.unit_amount,
|
||||||
|
quantity=entry.quantity,
|
||||||
|
unit_name=entry.unit_name,
|
||||||
occurred_at=entry.occurred_at,
|
occurred_at=entry.occurred_at,
|
||||||
operator=None, # provider 采集的记录没有经办人
|
operator=None, # provider 采集的记录没有经办人
|
||||||
source_module=entry.source_module,
|
source_module=entry.source_module,
|
||||||
|
|||||||
@@ -143,3 +143,53 @@ class CostEntryModelTests(TestCase):
|
|||||||
)
|
)
|
||||||
self.assertFalse(bool(entry.image1))
|
self.assertFalse(bool(entry.image1))
|
||||||
self.assertFalse(bool(entry.image2))
|
self.assertFalse(bool(entry.image2))
|
||||||
|
|
||||||
|
def test_formula_amount_calculated_on_create(self):
|
||||||
|
entry = cost_models.CostEntry.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
category=self.category,
|
||||||
|
amount=None,
|
||||||
|
unit_amount=Decimal('120.00'),
|
||||||
|
quantity=Decimal('2.5'),
|
||||||
|
unit_name='人天',
|
||||||
|
occurred_at=date(2026, 6, 1),
|
||||||
|
)
|
||||||
|
self.assertEqual(entry.amount, Decimal('300.00'))
|
||||||
|
self.assertEqual(entry.unit_name, '人天')
|
||||||
|
|
||||||
|
def test_formula_amount_recalculated_on_update(self):
|
||||||
|
entry = cost_models.CostEntry.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
category=self.category,
|
||||||
|
amount=Decimal('300.00'),
|
||||||
|
unit_amount=Decimal('100.00'),
|
||||||
|
quantity=Decimal('3'),
|
||||||
|
occurred_at=date(2026, 6, 1),
|
||||||
|
)
|
||||||
|
entry.quantity = Decimal('4')
|
||||||
|
entry.amount = Decimal('999.00')
|
||||||
|
entry.save()
|
||||||
|
entry.refresh_from_db()
|
||||||
|
self.assertEqual(entry.amount, Decimal('400.00'))
|
||||||
|
|
||||||
|
def test_formula_partial_fields_raise_validation_error(self):
|
||||||
|
with self.assertRaises(ValidationError) as ctx:
|
||||||
|
cost_models.CostEntry.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
category=self.category,
|
||||||
|
amount=None,
|
||||||
|
unit_amount=Decimal('100.00'),
|
||||||
|
quantity=None,
|
||||||
|
occurred_at=date(2026, 6, 1),
|
||||||
|
)
|
||||||
|
self.assertIn('quantity', ctx.exception.message_dict)
|
||||||
|
|
||||||
|
def test_manual_amount_required_without_formula(self):
|
||||||
|
with self.assertRaises(ValidationError) as ctx:
|
||||||
|
cost_models.CostEntry.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
category=self.category,
|
||||||
|
amount=None,
|
||||||
|
occurred_at=date(2026, 6, 1),
|
||||||
|
)
|
||||||
|
self.assertIn('amount', ctx.exception.message_dict)
|
||||||
|
|||||||
@@ -171,6 +171,33 @@ class CreateCostEntryTests(TestCase):
|
|||||||
self.assertEqual(entry.source_id, '')
|
self.assertEqual(entry.source_id, '')
|
||||||
self.assertEqual(entry.remarks, '')
|
self.assertEqual(entry.remarks, '')
|
||||||
|
|
||||||
|
def test_create_cost_entry_with_formula_fields(self):
|
||||||
|
"""传入 unit_amount + quantity 时自动计算最终 amount"""
|
||||||
|
entry = cost_services.create_cost_entry(
|
||||||
|
merchant=self.merchant,
|
||||||
|
category=self.category,
|
||||||
|
unit_amount=Decimal('180.00'),
|
||||||
|
quantity=Decimal('2'),
|
||||||
|
unit_name='人天',
|
||||||
|
occurred_at=date(2026, 6, 4),
|
||||||
|
)
|
||||||
|
self.assertEqual(entry.amount, Decimal('360.00'))
|
||||||
|
self.assertEqual(entry.unit_amount, Decimal('180.0000'))
|
||||||
|
self.assertEqual(entry.quantity, Decimal('2.0000'))
|
||||||
|
self.assertEqual(entry.unit_name, '人天')
|
||||||
|
|
||||||
|
def test_create_cost_entry_formula_overrides_amount(self):
|
||||||
|
"""倍数型支出以公式结果作为最终统计金额"""
|
||||||
|
entry = cost_services.create_cost_entry(
|
||||||
|
merchant=self.merchant,
|
||||||
|
category=self.category,
|
||||||
|
amount=Decimal('999.00'),
|
||||||
|
unit_amount=Decimal('120.00'),
|
||||||
|
quantity=Decimal('3'),
|
||||||
|
occurred_at=date(2026, 6, 4),
|
||||||
|
)
|
||||||
|
self.assertEqual(entry.amount, Decimal('360.00'))
|
||||||
|
|
||||||
|
|
||||||
class MockCostProvider:
|
class MockCostProvider:
|
||||||
"""测试用 Provider"""
|
"""测试用 Provider"""
|
||||||
@@ -222,6 +249,33 @@ class CollectFromProviderTests(TestCase):
|
|||||||
self.assertEqual(cost_models.CostEntry.objects.count(), 2)
|
self.assertEqual(cost_models.CostEntry.objects.count(), 2)
|
||||||
self.assertEqual(cost_models.CostCategory.objects.count(), 2)
|
self.assertEqual(cost_models.CostCategory.objects.count(), 2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_creates_formula_entries(self):
|
||||||
|
"""Provider 可以提供 unit_amount + quantity,由 cost 模块计算 amount"""
|
||||||
|
entries = [
|
||||||
|
CostEntryInput(
|
||||||
|
category_key='temp_worker', category_name='临时工工资',
|
||||||
|
amount=None, unit_amount=Decimal('200.00'), quantity=Decimal('3'),
|
||||||
|
unit_name='人天', occurred_at=date(2026, 6, 3),
|
||||||
|
source_module='test', source_id='T001',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
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['created_count'], 1)
|
||||||
|
entry = cost_models.CostEntry.objects.get()
|
||||||
|
self.assertEqual(entry.amount, Decimal('600.00'))
|
||||||
|
self.assertEqual(entry.unit_amount, Decimal('200.0000'))
|
||||||
|
self.assertEqual(entry.quantity, Decimal('3.0000'))
|
||||||
|
self.assertEqual(entry.unit_name, '人天')
|
||||||
|
|
||||||
def test_collect_returns_not_list_raises(self):
|
def test_collect_returns_not_list_raises(self):
|
||||||
"""Provider 返回非 list 抛 TypeError"""
|
"""Provider 返回非 list 抛 TypeError"""
|
||||||
class BadProvider:
|
class BadProvider:
|
||||||
|
|||||||
170
docs/api_v1_plate_order_detail_2026-06-30.md
Normal file
170
docs/api_v1_plate_order_detail_2026-06-30.md
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
# API v1 Plate Order Detail 字段文档
|
||||||
|
|
||||||
|
日期:2026-06-30
|
||||||
|
|
||||||
|
## 接口
|
||||||
|
|
||||||
|
`GET /api/v1/plate-orders/{id}/`
|
||||||
|
|
||||||
|
获取单个开版订单详情。
|
||||||
|
|
||||||
|
## 权限与隔离
|
||||||
|
|
||||||
|
- 需要登录。
|
||||||
|
- 使用 Django model permission:需要具备查看 `PlateOrder` 的权限。
|
||||||
|
- 非 superuser 默认受 merchant 隔离限制,只能访问当前用户员工所属 merchant 下的数据。
|
||||||
|
- 默认还受客户可见性限制;拥有 `printing.view_all_plateorders` 权限可突破客户可见性限制。
|
||||||
|
- 订单不存在或无权限访问时返回 `404`。
|
||||||
|
|
||||||
|
## 响应字段
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `id` | integer | 开版订单 ID |
|
||||||
|
| `original_id` | integer \| null | 克隆来源订单 ID |
|
||||||
|
| `merchant_id` | integer \| null | 所属商户 ID |
|
||||||
|
| `design_code` | string \| null | 设计编号 |
|
||||||
|
| `plate_type` | string \| null | 起版情况,例如首版、复版 |
|
||||||
|
| `plate_date` | datetime \| null | 下版时间 |
|
||||||
|
| `plate_method` | string \| null | 开版方式 |
|
||||||
|
| `plate_image` | array | 开版图原始数据列表 |
|
||||||
|
| `plate_image_url` | string[] | 开版图 URL 列表,从 `plate_image` 中提取并转换为可访问 URL |
|
||||||
|
| `image_name` | string \| null | 图片名称 |
|
||||||
|
| `plate_notes` | string \| null | 打版注意事项 |
|
||||||
|
| `reprint_reason` | string \| null | 复版原因 |
|
||||||
|
| `urgency_level` | string | 紧急程度 |
|
||||||
|
| `is_invalid` | boolean | 是否作废 |
|
||||||
|
| `customer` | integer | 客户 ID |
|
||||||
|
| `customer_name` | string | 客户名称 |
|
||||||
|
| `customer_phone` | string \| null | 客户手机号 |
|
||||||
|
| `area` | string \| null | 区域 |
|
||||||
|
| `default_address` | string \| null | 默认地址 |
|
||||||
|
| `salesperson` | integer \| null | 销售员员工 ID |
|
||||||
|
| `salesperson_name` | string \| null | 销售员姓名 |
|
||||||
|
| `merchandiser` | integer \| null | 跟单员员工 ID |
|
||||||
|
| `merchandiser_name` | string \| null | 跟单员姓名 |
|
||||||
|
| `designer` | integer \| null | 设计师员工 ID |
|
||||||
|
| `designer_name` | string \| null | 设计师姓名 |
|
||||||
|
| `style_name` | string \| null | 款号名称 |
|
||||||
|
| `fabric` | string \| null | 布料 |
|
||||||
|
| `fabric_source` | string \| null | 布料来源 |
|
||||||
|
| `width` | string \| null | 幅宽 |
|
||||||
|
| `production_method` | string \| null | 做货方式 |
|
||||||
|
| `is_mark_frame` | boolean | 是否套唛架 |
|
||||||
|
| `drawing_rating` | string \| null | 画图评级 |
|
||||||
|
| `color_matching_rating` | string \| null | 调色评级 |
|
||||||
|
| `sample_rating` | string \| null | 套样评级 |
|
||||||
|
| `difficulty_rating` | string \| null | 难度评级 |
|
||||||
|
| `sample_meter` | string \| null | 米样 |
|
||||||
|
| `required_sample_meters` | decimal string \| null | 客户要求米样米数 |
|
||||||
|
| `required_completion_date` | datetime \| null | 要求完成时间 |
|
||||||
|
| `completion_date` | datetime \| null | 完成时间 |
|
||||||
|
| `approval_result` | string \| null | 审批结果 |
|
||||||
|
| `is_ordered` | boolean | 是否已下单 |
|
||||||
|
| `customer_feedback` | string \| null | 客户修改意见 |
|
||||||
|
| `print_count` | integer | 打印次数 |
|
||||||
|
| `process` | integer | 关联流程 ID |
|
||||||
|
| `process_name` | string \| null | 流程名称 |
|
||||||
|
| `status` | string | 当前流程状态名称;无当前节点时通常表示已完成 |
|
||||||
|
| `status_id` | integer \| null | 当前待执行状态 ID |
|
||||||
|
| `is_completed` | boolean | 流程是否完成 |
|
||||||
|
| `has_started` | boolean | 是否已有未撤销的流程记录 |
|
||||||
|
| `progress_percentage` | integer | 流程进度百分比 |
|
||||||
|
| `business_object_id` | integer \| null | 关联流程实例 ID |
|
||||||
|
| `last_completed_state` | string | 最后一个已完成节点名称;没有时为空字符串 |
|
||||||
|
| `created_by` | integer \| null | 创建人用户 ID |
|
||||||
|
| `created_at` | datetime | 创建时间 |
|
||||||
|
| `updated_at` | datetime | 更新时间 |
|
||||||
|
|
||||||
|
## `plate_image` 数据形态
|
||||||
|
|
||||||
|
`plate_image` 是 JSON 数组,常见元素结构如下:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"file_id": 123,
|
||||||
|
"name": "图片名称",
|
||||||
|
"path": "uploads/example.jpg",
|
||||||
|
"url": "https://example.com/media/uploads/example.jpg",
|
||||||
|
"size": 102400,
|
||||||
|
"content_type": "image/jpeg",
|
||||||
|
"uploaded_at": "2026-06-30T10:00:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
兼容历史数据时,元素也可能只有 `url` 或 `path`。前端展示图片优先使用 `plate_image_url`。
|
||||||
|
|
||||||
|
## 响应示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 18839,
|
||||||
|
"original_id": null,
|
||||||
|
"merchant_id": 1,
|
||||||
|
"design_code": "18839",
|
||||||
|
"plate_type": "首版",
|
||||||
|
"plate_date": "2026-06-30T10:00:00+08:00",
|
||||||
|
"plate_method": "普通开版",
|
||||||
|
"plate_image": [
|
||||||
|
{
|
||||||
|
"file_id": 123,
|
||||||
|
"name": "开版图",
|
||||||
|
"path": "uploads/plate/example.jpg",
|
||||||
|
"url": "https://example.com/media/uploads/plate/example.jpg",
|
||||||
|
"size": 102400,
|
||||||
|
"content_type": "image/jpeg",
|
||||||
|
"uploaded_at": "2026-06-30T09:58:00+08:00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"plate_image_url": [
|
||||||
|
"https://example.com/media/uploads/plate/example.jpg"
|
||||||
|
],
|
||||||
|
"image_name": "开版图",
|
||||||
|
"plate_notes": "注意事项",
|
||||||
|
"reprint_reason": null,
|
||||||
|
"urgency_level": "正常",
|
||||||
|
"is_invalid": false,
|
||||||
|
"customer": 1001,
|
||||||
|
"customer_name": "示例客户",
|
||||||
|
"customer_phone": "13800000000",
|
||||||
|
"area": "广州",
|
||||||
|
"default_address": "广州市示例地址",
|
||||||
|
"salesperson": 11,
|
||||||
|
"salesperson_name": "销售员A",
|
||||||
|
"merchandiser": 12,
|
||||||
|
"merchandiser_name": "跟单员B",
|
||||||
|
"designer": 13,
|
||||||
|
"designer_name": "设计师C",
|
||||||
|
"style_name": "款号001",
|
||||||
|
"fabric": "棉布",
|
||||||
|
"fabric_source": "客户来布",
|
||||||
|
"width": "150cm",
|
||||||
|
"production_method": "印花",
|
||||||
|
"is_mark_frame": false,
|
||||||
|
"drawing_rating": null,
|
||||||
|
"color_matching_rating": null,
|
||||||
|
"sample_rating": null,
|
||||||
|
"difficulty_rating": null,
|
||||||
|
"sample_meter": "米样说明",
|
||||||
|
"required_sample_meters": "5.00",
|
||||||
|
"required_completion_date": "2026-07-01T18:00:00+08:00",
|
||||||
|
"completion_date": null,
|
||||||
|
"approval_result": null,
|
||||||
|
"is_ordered": false,
|
||||||
|
"customer_feedback": null,
|
||||||
|
"print_count": 0,
|
||||||
|
"process": 1,
|
||||||
|
"process_name": "开版流程",
|
||||||
|
"status": "画图",
|
||||||
|
"status_id": 2,
|
||||||
|
"is_completed": false,
|
||||||
|
"has_started": true,
|
||||||
|
"progress_percentage": 25,
|
||||||
|
"business_object_id": 5001,
|
||||||
|
"last_completed_state": "接单",
|
||||||
|
"created_by": 7,
|
||||||
|
"created_at": "2026-06-30T09:50:00+08:00",
|
||||||
|
"updated_at": "2026-06-30T10:10:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
@@ -96,6 +96,8 @@
|
|||||||
"id": 1,
|
"id": 1,
|
||||||
"merchant": 10,
|
"merchant": 10,
|
||||||
"name": "通用",
|
"name": "通用",
|
||||||
|
"payload_processor": "",
|
||||||
|
"speech_enabled": false,
|
||||||
"created_at": "2026-04-10T12:00:00+08:00",
|
"created_at": "2026-04-10T12:00:00+08:00",
|
||||||
"updated_at": "2026-04-10T12:00:00+08:00"
|
"updated_at": "2026-04-10T12:00:00+08:00"
|
||||||
}
|
}
|
||||||
@@ -287,6 +289,7 @@
|
|||||||
| `merchant` | int | 所属商户 ID |
|
| `merchant` | int | 所属商户 ID |
|
||||||
| `name` | string | 分类名称 |
|
| `name` | string | 分类名称 |
|
||||||
| `payload_processor` | string | payload 增强器标识,未启用时为空字符串 |
|
| `payload_processor` | string | payload 增强器标识,未启用时为空字符串 |
|
||||||
|
| `speech_enabled` | boolean | 该分类创建任务时是否触发语音播报;默认 `false` |
|
||||||
| `created_at` | datetime | 创建时间 |
|
| `created_at` | datetime | 创建时间 |
|
||||||
| `updated_at` | datetime | 更新时间 |
|
| `updated_at` | datetime | 更新时间 |
|
||||||
|
|
||||||
@@ -301,13 +304,15 @@
|
|||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
| `name` | string | 是 | 分类名称,同商户下唯一 |
|
| `name` | string | 是 | 分类名称,同商户下唯一 |
|
||||||
| `payload_processor` | string | 否 | payload 增强器标识;当前可选值:`structured_description_v1` |
|
| `payload_processor` | string | 否 | payload 增强器标识;当前可选值:`structured_description_v1` |
|
||||||
|
| `speech_enabled` | boolean | 否 | 是否启用该分类的任务创建语音播报;默认 `false` |
|
||||||
|
|
||||||
请求示例:
|
请求示例:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"name": "售后",
|
"name": "售后",
|
||||||
"payload_processor": "structured_description_v1"
|
"payload_processor": "structured_description_v1",
|
||||||
|
"speech_enabled": true
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -331,6 +336,7 @@
|
|||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `name` | string | 分类名称,同商户下唯一 |
|
| `name` | string | 分类名称,同商户下唯一 |
|
||||||
| `payload_processor` | string | payload 增强器标识;传空字符串表示清空 |
|
| `payload_processor` | string | payload 增强器标识;传空字符串表示清空 |
|
||||||
|
| `speech_enabled` | boolean | 是否启用该分类的任务创建语音播报 |
|
||||||
|
|
||||||
成功响应:`MissionCategory`
|
成功响应:`MissionCategory`
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,29 @@
|
|||||||
|
|
||||||
本文档面向前端,描述 `cost` 成本模块的 API。
|
本文档面向前端,描述 `cost` 成本模块的 API。
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-06-30 前端变更摘要
|
||||||
|
|
||||||
|
本次支出明细接口兼容新增“倍数型支出”字段。支出类目接口无变化,按类目汇总接口的响应结构无变化,但汇总金额仍来自 `amount`。
|
||||||
|
|
||||||
|
**新增字段(支出明细列表、详情、创建、修改均涉及):**
|
||||||
|
|
||||||
|
| 字段 | 类型 | 位置 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `unit_amount` | decimal string / null | request + response | 单价 / 基数金额,如临时工日薪。响应中为字符串,如 `"200.0000"` |
|
||||||
|
| `quantity` | decimal string / null | request + response | 数量 / 倍数,如 `"3.0000"` |
|
||||||
|
| `unit_name` | string | request + response | 单位名称,如 `人天`、`小时`、`件`,可为空字符串 |
|
||||||
|
|
||||||
|
**兼容规则:**
|
||||||
|
|
||||||
|
- 普通支出:继续传 `amount`,不传 `unit_amount`、`quantity` 即可。
|
||||||
|
- 倍数型支出:传 `unit_amount + quantity`,`amount` 可不传;后端保存时计算 `amount = unit_amount * quantity`。
|
||||||
|
- 如果同时传 `amount` 和 `unit_amount + quantity`,后端以公式计算结果为准,返回的 `amount` 是计算后的最终金额。
|
||||||
|
- `unit_amount` 和 `quantity` 必须同时填写或同时为 `null`/不传;只传一个会返回 `400`。
|
||||||
|
- 将倍数型支出改回普通支出时,PUT 需要同时传:`amount`、`unit_amount: null`、`quantity: null`,`unit_name` 可传空字符串。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 基本约定
|
## 基本约定
|
||||||
|
|
||||||
- Base URL: `/api/v2`
|
- Base URL: `/api/v2`
|
||||||
@@ -153,6 +176,9 @@ GET /api/v2/cost-entries/
|
|||||||
"category_id": 1,
|
"category_id": 1,
|
||||||
"category_name": "电费",
|
"category_name": "电费",
|
||||||
"amount": "350.00",
|
"amount": "350.00",
|
||||||
|
"unit_amount": null,
|
||||||
|
"quantity": null,
|
||||||
|
"unit_name": "",
|
||||||
"occurred_at": "2026-06-01",
|
"occurred_at": "2026-06-01",
|
||||||
"operator_id": 12,
|
"operator_id": 12,
|
||||||
"image1": "",
|
"image1": "",
|
||||||
@@ -170,7 +196,10 @@ GET /api/v2/cost-entries/
|
|||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `amount` | string | 金额(Decimal 转字符串,前端展示时注意格式化) |
|
| `amount` | string | 最终支出金额 / 统计金额(Decimal 转字符串,前端展示时注意格式化) |
|
||||||
|
| `unit_amount` | string/null | 单价 / 基数金额;普通支出为 `null` |
|
||||||
|
| `quantity` | string/null | 数量 / 倍数;普通支出为 `null` |
|
||||||
|
| `unit_name` | string | 单位名称;普通支出为空字符串 |
|
||||||
| `occurred_at` | string | 发生日期,`YYYY-MM-DD` |
|
| `occurred_at` | string | 发生日期,`YYYY-MM-DD` |
|
||||||
| `operator_id` | int | 经办人 ID,自动从当前登录用户获取 |
|
| `operator_id` | int | 经办人 ID,自动从当前登录用户获取 |
|
||||||
| `image1` | string | 凭证图片 URL(七牛云 CDN),无则为空字符串 |
|
| `image1` | string | 凭证图片 URL(七牛云 CDN),无则为空字符串 |
|
||||||
@@ -191,7 +220,10 @@ Content-Type: `multipart/form-data`(支持图片上传)或 `application/json
|
|||||||
| 字段 | 类型 | 必填 | 说明 |
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
| `category_id` | int | **是** | 支出类目 ID |
|
| `category_id` | int | **是** | 支出类目 ID |
|
||||||
| `amount` | decimal | **是** | 金额,如 `"350.00"` |
|
| `amount` | decimal | 条件必填 | 普通支出必填;倍数型支出可不传。最终统计金额,如 `"350.00"` |
|
||||||
|
| `unit_amount` | decimal | 否 | **新增**。单价 / 基数金额;和 `quantity` 必须同时填写或同时为空 |
|
||||||
|
| `quantity` | decimal | 否 | **新增**。数量 / 倍数;和 `unit_amount` 必须同时填写或同时为空 |
|
||||||
|
| `unit_name` | string | 否 | **新增**。单位名称,如 `人天`、`小时`、`件` |
|
||||||
| `occurred_at` | date | **是** | 发生日期,`YYYY-MM-DD` |
|
| `occurred_at` | date | **是** | 发生日期,`YYYY-MM-DD` |
|
||||||
| `image1` | file | 否 | 凭证图片 |
|
| `image1` | file | 否 | 凭证图片 |
|
||||||
| `image2` | file | 否 | 备用凭证图片 |
|
| `image2` | file | 否 | 备用凭证图片 |
|
||||||
@@ -199,7 +231,7 @@ Content-Type: `multipart/form-data`(支持图片上传)或 `application/json
|
|||||||
| `source_id` | string | 否 | 来源记录 ID |
|
| `source_id` | string | 否 | 来源记录 ID |
|
||||||
| `remarks` | string | 否 | 备注 |
|
| `remarks` | string | 否 | 备注 |
|
||||||
|
|
||||||
请求示例(JSON):
|
请求示例(JSON,普通支出):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -210,6 +242,21 @@ Content-Type: `multipart/form-data`(支持图片上传)或 `application/json
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
请求示例(JSON,**新增:倍数型支出**):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"category_id": 2,
|
||||||
|
"unit_amount": "200.00",
|
||||||
|
"quantity": "3",
|
||||||
|
"unit_name": "人天",
|
||||||
|
"occurred_at": "2026-06-01",
|
||||||
|
"remarks": "临时工 3 人天"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
倍数型支出成功响应中的 `amount` 会是后端计算后的最终金额,例如 `"600.00"`。
|
||||||
|
|
||||||
成功响应 `201`:返回创建的支出明细对象。
|
成功响应 `201`:返回创建的支出明细对象。
|
||||||
|
|
||||||
错误响应 `400`:校验失败。
|
错误响应 `400`:校验失败。
|
||||||
@@ -238,7 +285,10 @@ Content-Type: `multipart/form-data` 或 `application/json`
|
|||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `category_id` | int | 切换类目 |
|
| `category_id` | int | 切换类目 |
|
||||||
| `amount` | decimal | 金额 |
|
| `amount` | decimal/null | 普通支出金额;倍数型支出若公式字段存在,会被后端公式结果覆盖 |
|
||||||
|
| `unit_amount` | decimal/null | **新增**。单价 / 基数金额;传 `null` 可清除公式字段 |
|
||||||
|
| `quantity` | decimal/null | **新增**。数量 / 倍数;传 `null` 可清除公式字段 |
|
||||||
|
| `unit_name` | string | **新增**。单位名称;可传空字符串清空 |
|
||||||
| `occurred_at` | date | 发生日期 |
|
| `occurred_at` | date | 发生日期 |
|
||||||
| `image1` | file | 凭证图片 |
|
| `image1` | file | 凭证图片 |
|
||||||
| `image2` | file | 备用凭证图片 |
|
| `image2` | file | 备用凭证图片 |
|
||||||
@@ -248,6 +298,27 @@ Content-Type: `multipart/form-data` 或 `application/json`
|
|||||||
|
|
||||||
成功响应 `200`:返回更新后的支出明细对象。
|
成功响应 `200`:返回更新后的支出明细对象。
|
||||||
|
|
||||||
|
修改倍数型支出数量示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"quantity": "4"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
后端会按已有 `unit_amount * quantity` 重算并返回新的 `amount`。
|
||||||
|
|
||||||
|
将倍数型支出改回普通支出示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"amount": "550.00",
|
||||||
|
"unit_amount": null,
|
||||||
|
"quantity": null,
|
||||||
|
"unit_name": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### 2.5 删除支出明细
|
### 2.5 删除支出明细
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -58,7 +58,10 @@ api_v2/views/cost.py # API View(不放在 cost 内部)
|
|||||||
| `id` | BigAutoField (PK) | |
|
| `id` | BigAutoField (PK) | |
|
||||||
| `merchant` | FK → Merchant | 所属商户 |
|
| `merchant` | FK → Merchant | 所属商户 |
|
||||||
| `category` | FK → CostCategory | 支出类目 |
|
| `category` | FK → CostCategory | 支出类目 |
|
||||||
| `amount` | DecimalField(15, 2) | 金额 |
|
| `amount` | DecimalField(15, 2) | 最终支出金额 / 统计金额。普通支出手工填写;倍数型支出由 `unit_amount * quantity` 计算写入 |
|
||||||
|
| `unit_amount` | DecimalField(15, 4, null) | 单价 / 基数金额,如临时工日薪 |
|
||||||
|
| `quantity` | DecimalField(12, 4, null) | 数量 / 倍数,如人天、小时、件数 |
|
||||||
|
| `unit_name` | CharField(max_length=20, null) | 单位名称,如 `人天`、`小时`、`件` |
|
||||||
| `occurred_at` | DateField | 发生日期 |
|
| `occurred_at` | DateField | 发生日期 |
|
||||||
| `operator` | FK → Employee | 经办人 |
|
| `operator` | FK → Employee | 经办人 |
|
||||||
| `image1` | ImageField (null) | 凭证图片 |
|
| `image1` | ImageField (null) | 凭证图片 |
|
||||||
@@ -80,10 +83,13 @@ api_v2/views/cost.py # API View(不放在 cost 内部)
|
|||||||
class CostEntryInput:
|
class CostEntryInput:
|
||||||
category_key: str # 类目标识键,用于匹配 CostCategory.unique_key
|
category_key: str # 类目标识键,用于匹配 CostCategory.unique_key
|
||||||
category_name: str # 类目显示名(匹配不到时用此名自动创建)
|
category_name: str # 类目显示名(匹配不到时用此名自动创建)
|
||||||
amount: Decimal
|
amount: Decimal | None # 最终金额;倍数型支出可传 None,由 unit_amount * quantity 计算
|
||||||
occurred_at: date
|
occurred_at: date
|
||||||
source_module: str
|
source_module: str
|
||||||
source_id: str
|
source_id: str
|
||||||
|
unit_amount: Decimal | None = None
|
||||||
|
quantity: Decimal | None = None
|
||||||
|
unit_name: str = ''
|
||||||
remarks: str = ''
|
remarks: str = ''
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -143,10 +149,24 @@ class PrintingCostProvider:
|
|||||||
| 函数 | 说明 |
|
| 函数 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `ensure_category(*, merchant, category_key, category_name)` | 按 key 查找或创建支出类目 |
|
| `ensure_category(*, merchant, category_key, category_name)` | 按 key 查找或创建支出类目 |
|
||||||
| `create_cost_entry(*, merchant, category, amount, occurred_at, operator, image1, image2, source_module, source_id, remarks)` | 创建支出记录 |
|
| `create_cost_entry(*, merchant, category, occurred_at, amount, unit_amount, quantity, unit_name, operator, image1, image2, source_module, source_id, remarks)` | 创建支出记录;普通支出使用 `amount`,倍数型支出使用 `unit_amount + quantity` 自动计算最终 `amount` |
|
||||||
| `collect_from_provider(provider, *, merchant, start_date, end_date)` | 从 Provider 采集成本数据 |
|
| `collect_from_provider(provider, *, merchant, start_date, end_date)` | 从 Provider 采集成本数据 |
|
||||||
| `aggregate_by_category(*, merchant, start_date, end_date)` | 按类别汇总(group by category) |
|
| `aggregate_by_category(*, merchant, start_date, end_date)` | 按类别汇总(group by category) |
|
||||||
|
|
||||||
|
|
||||||
|
### 5.1 金额公式与写入约束
|
||||||
|
|
||||||
|
`CostEntry.amount` 永远表示最终支出金额,也是所有统计、排序、报表的唯一金额口径。倍数型支出使用 `unit_amount * quantity` 推导最终金额,保存时写回 `amount`;普通支出不填写公式字段,直接保存手工 `amount`。
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- `unit_amount` 和 `quantity` 必须同时填写或同时为空。
|
||||||
|
- 当 `unit_amount` 和 `quantity` 同时存在时,`amount` 以公式计算结果为准,手工传入的 `amount` 会被覆盖。
|
||||||
|
- 当公式字段为空时,`amount` 必须填写。
|
||||||
|
- `unit_name` 只用于展示单位,不参与金额计算。
|
||||||
|
|
||||||
|
> **WARNING: 禁止使用 `QuerySet.update()`、`bulk_update()` 或 SQL 直接更新 `CostEntry.amount`、`unit_amount`、`quantity`。这些写法不会触发 `CostEntry.save()`,会绕过金额公式重算,可能造成统计金额错误。更新支出明细必须使用 service 入口或实例 `save()`;如果确实需要批量修正,必须编写专门的数据迁移/管理命令,并在命令内逐条调用 `save()`。**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. API 设计 (api_v2)
|
## 6. API 设计 (api_v2)
|
||||||
@@ -178,3 +198,5 @@ class PrintingCostProvider:
|
|||||||
| 5 | `CostEntry` 带两个 `ImageField` | 用户要求:一个用于凭证图片,一个预留备用 |
|
| 5 | `CostEntry` 带两个 `ImageField` | 用户要求:一个用于凭证图片,一个预留备用 |
|
||||||
| 6 | 汇总 API 按 `start/end` 时间段 + `group by category` | 用户指定的统计方式 |
|
| 6 | 汇总 API 按 `start/end` 时间段 + `group by category` | 用户指定的统计方式 |
|
||||||
| 7 | 第一版不做 Provider 注册表 | Provider 暂时只有一个调用入口 `collect_from_provider()`,后续可扩展为注册表模式 |
|
| 7 | 第一版不做 Provider 注册表 | Provider 暂时只有一个调用入口 `collect_from_provider()`,后续可扩展为注册表模式 |
|
||||||
|
| 8 | `amount` 固定为最终统计金额 | 兼容普通金额支出与倍数型支出,避免统计层判断 `amount` 的双重语义 |
|
||||||
|
| 9 | 金额公式在 `CostEntry.save()` 兜底计算 | 保证 create/update 经实例保存时都能重算 `amount`,service 层作为推荐业务入口 |
|
||||||
|
|||||||
118
docs/好布业金额计算报告.md
Normal file
118
docs/好布业金额计算报告.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# 好布业 ERP 金额计算规则报告
|
||||||
|
|
||||||
|
**—— 计价引擎舍入规则逆向分析 ——**
|
||||||
|
|
||||||
|
编制日期:2026 年 6 月
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、结论摘要
|
||||||
|
|
||||||
|
经过逐步测试与逻辑反推,好布业 ERP 系统的金额计算规则已被完整还原。核心规则可用一行公式表达:
|
||||||
|
|
||||||
|
> **总价 = ROUND ( 数量 × 单价 , 0 ) —— 逢五进一(round-half-up)**
|
||||||
|
|
||||||
|
**关键特征:** 输入阶段不舍入,按原始小数相乘;只在生成总价时把乘积四舍五入到整数(元),逢 0.5 进位。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、分析背景
|
||||||
|
|
||||||
|
在日常使用中发现,系统的总价金额始终为整数,单价或数量中携带的小数似乎在某一环节被处理掉了。为弄清系统究竟在哪一步、按什么规则处理小数,本次分析采用受控输入测试法:固定其他变量,逐组输入特定数值,观察输出总价,从而逆向推断计算引擎的真实行为。
|
||||||
|
|
||||||
|
需要回答三个核心问题:
|
||||||
|
|
||||||
|
- 舍入规则是什么——四舍五入、截断(向下取整)还是其他?
|
||||||
|
- 舍入发生在哪一步——输入阶段(先舍单价/数量)还是乘积阶段(先乘后舍)?
|
||||||
|
- 逢五如何处理——逢五进一还是银行家舍入(逢五取偶)?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、测试数据与观察
|
||||||
|
|
||||||
|
以下为全部实测数据,按测试目的分组列出。
|
||||||
|
|
||||||
|
### 3.1 基础舍入测试
|
||||||
|
|
||||||
|
| 数量 | 单价 | 理论乘积 | 实际总价 | 说明 |
|
||||||
|
|:---:|:---:|:---:|:---:|:---:|
|
||||||
|
| 1 | 1.4 | 1.4 | **1** | 舍去 |
|
||||||
|
| 1 | 1.49 | 1.49 | **1** | 舍去 |
|
||||||
|
| 1 | 1.5 | 1.5 | **2** | 进位 |
|
||||||
|
|
||||||
|
**观察:** 1.5 → 2 进位,说明并非截断(若为截断/向下取整,1.5 应得 1)。结合 1.49 → 1,规则锁定为四舍五入。
|
||||||
|
|
||||||
|
### 3.2 对称性测试(小数放数量 vs 放单价)
|
||||||
|
|
||||||
|
| 数量 | 单价 | 实际总价 | 说明 |
|
||||||
|
|:---:|:---:|:---:|:---:|
|
||||||
|
| 1.49 | 1 | **1** | 小数在数量 |
|
||||||
|
| 1 | 1.49 | **1** | 小数在单价 |
|
||||||
|
| 1.5 | 1 | **2** | 小数在数量 |
|
||||||
|
| 1 | 1.5 | **2** | 小数在单价 |
|
||||||
|
|
||||||
|
**观察:** 无论小数落在数量还是单价,处理结果一致,舍入对称。但这些组合均有一个因子为 1,乘积等于小数本身,无法区分“先舍输入”与“先乘后舍”。
|
||||||
|
|
||||||
|
### 3.3 关键测试:舍入发生在哪一步
|
||||||
|
|
||||||
|
使两个因子都不为整数且不为 1,让两种引擎给出不同结果:
|
||||||
|
|
||||||
|
| 数量 | 单价 | 先舍输入预期 | 先乘后舍预期 | 实际总价 |
|
||||||
|
|:---:|:---:|:---:|:---:|:---:|
|
||||||
|
| 1.5 | 1.5 | 2×2 = 4 | round(2.25) = 2 | **2** |
|
||||||
|
|
||||||
|
**结论:** 实际为 2,等于 round(1.5 × 1.5) = round(2.25)。证明系统先按原始小数相乘、再对乘积舍入,输入阶段不舍入。同时否定了“单价/数量在进入计算前即被取整”及“字段为 INT”的早期猜测——内部计算至少保留小数(很可能为 decimal)。
|
||||||
|
|
||||||
|
### 3.4 收尾测试:逢五进一 vs 银行家舍入
|
||||||
|
|
||||||
|
此前的进位样本(1.5→2、2.25→2)恰好两种规则结果相同,无法区分。选取乘积正好为 2.5 的点(2 为偶数,银行家舍入会归 2)进行判定:
|
||||||
|
|
||||||
|
| 数量 | 单价 | 逢五进一预期 | 银行家舍入预期 | 实际总价 |
|
||||||
|
|:---:|:---:|:---:|:---:|:---:|
|
||||||
|
| 1 | 2.5 | 3 | 2 | **3** |
|
||||||
|
|
||||||
|
**结论:** 实际为 3,确认为逢五进一(round-half-up),排除银行家舍入。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、最终规则与计算流程
|
||||||
|
|
||||||
|
综合全部测试,系统计价流程如下:
|
||||||
|
|
||||||
|
| 环节 | 行为 |
|
||||||
|
|:---|:---|
|
||||||
|
| **输入(数量、单价)** | 保留原始小数,不做任何舍入 |
|
||||||
|
| **乘法** | 用原始小数相乘,得到带小数的乘积 |
|
||||||
|
| **生成总价** | 对乘积四舍五入到整数;乘积小数部分 ≥ 0.5 进位(逢五进一,非银行家舍入) |
|
||||||
|
| **存储 / 显示** | 以整数(元)为单位呈现 |
|
||||||
|
|
||||||
|
**计算流程示意:**
|
||||||
|
|
||||||
|
```
|
||||||
|
输入(原始小数) → 数量 × 单价(保留小数) → ROUND 到整数(逢五进一) → 整数元存储/显示
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、系统性质判定
|
||||||
|
|
||||||
|
- **精度等级:** 对外结算精度为 0 位小数(整数元),但内部计算保留小数参与运算。
|
||||||
|
- **舍入位置:** 位于乘积阶段(先乘后舍),而非输入阶段。
|
||||||
|
- **舍入方式:** 四舍五入、逢五进一(round-half-up)。
|
||||||
|
- **数据本质:** 金额字段对外表现为整数,但计算层非 INT 截断,更接近 decimal 计算后取整。
|
||||||
|
- **适用定性:** 属于“元级整数结算”的简化型计价系统,不处理角、分、厘等小单位。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、使用建议与注意事项
|
||||||
|
|
||||||
|
- 单条记录的小数部分会被舍入,多条汇总时可能产生“先汇总再取整”与“逐条取整再相加”的差异,对账时需明确以哪种口径为准。
|
||||||
|
- 由于逢五进一,金额在临界值(乘积恰为 X.5)会整体偏高,长期累积对总额有轻微上偏影响,财务核算时可留意。
|
||||||
|
- 若需更高精度(保留角/分),需在系统层调整结算精度,单纯依赖现有规则无法还原小数金额。
|
||||||
|
- 建议在正式启用前,对“数量与单价均带两位以上小数”的组合再抽样复核,确保乘积舍入行为在更复杂数值下依旧一致。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录:规则一句话总结
|
||||||
|
|
||||||
|
> 该 ERP 系统在金额计算中,按原始小数完成「数量 × 单价」后,对乘积统一四舍五入(逢五进一)为整数元,输入阶段不舍入,最终结算单位为元级整数金额。
|
||||||
@@ -92,8 +92,12 @@ MESSAGE_API_DEFAULT_NEWS_IMAGE_URL = env(
|
|||||||
'MESSAGE_API_DEFAULT_NEWS_IMAGE_URL',
|
'MESSAGE_API_DEFAULT_NEWS_IMAGE_URL',
|
||||||
default='https://via.placeholder.com/640x360.png?text=No+Image',
|
default='https://via.placeholder.com/640x360.png?text=No+Image',
|
||||||
)
|
)
|
||||||
SPEAK_ENDPOINT = env('SPEAK_ENDPOINT', default='http://8.148.215.233:9004/speak')
|
SPEECH_ENABLED = True
|
||||||
|
SPEAK_ENDPOINT = env('SPEAK_ENDPOINT', default='https://tts.yuwen.cloud/speak')
|
||||||
|
SPEAK_API_KEY = 'wobushixiaoai_1216'
|
||||||
PRINTING_ORDER_CREATED_SPEECH_ENABLED = False
|
PRINTING_ORDER_CREATED_SPEECH_ENABLED = False
|
||||||
|
BUSINESS_ORDER_SPEECH_NOTIFY_ENABLED = True
|
||||||
|
MISSION_SPEECH_NOTIFY_ENABLED = True
|
||||||
AGENT_ACCESS_KEY = env('AGENT_ACCESS_KEY', default='')
|
AGENT_ACCESS_KEY = env('AGENT_ACCESS_KEY', default='')
|
||||||
|
|
||||||
# HaoBuYe 外部财务同步
|
# HaoBuYe 外部财务同步
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ def play_speech(
|
|||||||
content = (text or '').strip()
|
content = (text or '').strip()
|
||||||
if not content:
|
if not content:
|
||||||
raise ValueError('text 不能为空')
|
raise ValueError('text 不能为空')
|
||||||
|
if not getattr(settings, 'SPEECH_ENABLED', True):
|
||||||
|
return SpeakResponse(status_code=0, raw={'status': 'disabled'})
|
||||||
|
|
||||||
url = (endpoint or getattr(settings, 'SPEAK_ENDPOINT', '') or '').strip()
|
url = (endpoint or getattr(settings, 'SPEAK_ENDPOINT', '') or '').strip()
|
||||||
if not url:
|
if not url:
|
||||||
@@ -38,10 +40,14 @@ def play_speech(
|
|||||||
|
|
||||||
payload = {'text': content}
|
payload = {'text': content}
|
||||||
data = json.dumps(payload, ensure_ascii=False).encode('utf-8')
|
data = json.dumps(payload, ensure_ascii=False).encode('utf-8')
|
||||||
|
headers = {'Content-Type': 'application/json'}
|
||||||
|
api_key = (getattr(settings, 'SPEAK_API_KEY', '') or '').strip()
|
||||||
|
if api_key:
|
||||||
|
headers['X-Api-Key'] = api_key
|
||||||
req = Request(
|
req = Request(
|
||||||
url=url,
|
url=url,
|
||||||
data=data,
|
data=data,
|
||||||
headers={'Content-Type': 'application/json'},
|
headers=headers,
|
||||||
method='POST',
|
method='POST',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ from mission.models import Mission, MissionCategory, MissionParticipant, Mission
|
|||||||
|
|
||||||
@admin.register(MissionCategory)
|
@admin.register(MissionCategory)
|
||||||
class MissionCategoryAdmin(admin.ModelAdmin):
|
class MissionCategoryAdmin(admin.ModelAdmin):
|
||||||
list_display = ["id", "merchant", "name", "payload_processor", "created_at"]
|
list_display = ["id", "merchant", "name", "payload_processor", "speech_enabled", "created_at"]
|
||||||
list_filter = ["merchant", "payload_processor", "created_at"]
|
list_filter = ["merchant", "payload_processor", "speech_enabled", "created_at"]
|
||||||
search_fields = ["name"]
|
search_fields = ["name"]
|
||||||
readonly_fields = ["created_at", "updated_at"]
|
readonly_fields = ["created_at", "updated_at"]
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from mission.notifications import enqueue_mission_created_speech
|
||||||
from mission.payload_processors import apply_mission_payload_processor
|
from mission.payload_processors import apply_mission_payload_processor
|
||||||
from notifier.models import NotificationEventKeyEnum
|
from notifier.models import NotificationEventKeyEnum
|
||||||
from notifier.services import enqueue_notification_event
|
from notifier.services import enqueue_notification_event
|
||||||
@@ -74,6 +75,7 @@ def on_mission_created(sender, instance, created_by=None, **kwargs):
|
|||||||
merchant_id=instance.merchant_id,
|
merchant_id=instance.merchant_id,
|
||||||
payload=payload,
|
payload=payload,
|
||||||
)
|
)
|
||||||
|
enqueue_mission_created_speech(mission=instance)
|
||||||
|
|
||||||
|
|
||||||
def on_mission_replied(sender, instance, mission=None, responder=None, **kwargs):
|
def on_mission_replied(sender, instance, mission=None, responder=None, **kwargs):
|
||||||
|
|||||||
16
mission/migrations/0008_missioncategory_speech_enabled.py
Normal file
16
mission/migrations/0008_missioncategory_speech_enabled.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("mission", "0007_missioncategory_payload_processor"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="missioncategory",
|
||||||
|
name="speech_enabled",
|
||||||
|
field=models.BooleanField(default=False, verbose_name="是否播报"),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -28,6 +28,7 @@ class MissionCategory(ModelBase):
|
|||||||
choices=MissionPayloadProcessorEnum.choices,
|
choices=MissionPayloadProcessorEnum.choices,
|
||||||
verbose_name="payload 增强器",
|
verbose_name="payload 增强器",
|
||||||
)
|
)
|
||||||
|
speech_enabled = models.BooleanField(default=False, verbose_name="是否播报")
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|||||||
36
mission/notifications.py
Normal file
36
mission/notifications.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue_mission_created_speech(*, mission) -> None:
|
||||||
|
if not getattr(settings, "SPEECH_ENABLED", True):
|
||||||
|
return
|
||||||
|
if not getattr(settings, "MISSION_SPEECH_NOTIFY_ENABLED", True):
|
||||||
|
return
|
||||||
|
|
||||||
|
category = getattr(mission, "category", None)
|
||||||
|
if not getattr(category, "speech_enabled", False):
|
||||||
|
return
|
||||||
|
|
||||||
|
text = _build_mission_created_speech_text(mission=mission)
|
||||||
|
try:
|
||||||
|
from mission.tasks import notify_mission_speech
|
||||||
|
|
||||||
|
notify_mission_speech.delay(text=text)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("[mission.notifications] 投递任务语音播报任务失败(已忽略)")
|
||||||
|
|
||||||
|
|
||||||
|
def _build_mission_created_speech_text(*, mission) -> str:
|
||||||
|
category_name = getattr(getattr(mission, "category", None), "name", "任务") or "任务"
|
||||||
|
creator_name = getattr(getattr(mission, "creator", None), "name", "") or ""
|
||||||
|
prefix = f"{creator_name}创建了" if creator_name else "创建了"
|
||||||
|
description = (getattr(mission, "description", "") or "").strip()
|
||||||
|
if len(description) > 60:
|
||||||
|
description = f"{description[:60]}..."
|
||||||
|
if description:
|
||||||
|
return f"{prefix}{category_name}任务,{description}"
|
||||||
|
return f"{prefix}{category_name}任务"
|
||||||
27
mission/tasks.py
Normal file
27
mission/tasks.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import logging
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
from celery import shared_task
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(bind=True)
|
||||||
|
def notify_mission_speech(self, *, text: str) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
from flower.utils import play_speech
|
||||||
|
|
||||||
|
response = play_speech(text=text, timeout_seconds=3.0)
|
||||||
|
return {
|
||||||
|
"status": "sent",
|
||||||
|
"task_id": self.request.id,
|
||||||
|
"status_code": response.status_code,
|
||||||
|
"raw": response.raw,
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("[mission.tasks] 任务语音播报失败(已忽略)")
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"task_id": self.request.id,
|
||||||
|
"error": str(exc),
|
||||||
|
}
|
||||||
@@ -580,6 +580,28 @@ class MissionModelTestCase(TestCase):
|
|||||||
self.assertEqual(mock_enqueue.call_args.kwargs["merchant_id"], self.merchant.id)
|
self.assertEqual(mock_enqueue.call_args.kwargs["merchant_id"], self.merchant.id)
|
||||||
self.assertEqual(mock_enqueue.call_args.kwargs["payload"]["mission_id"], mission.id)
|
self.assertEqual(mock_enqueue.call_args.kwargs["payload"]["mission_id"], mission.id)
|
||||||
|
|
||||||
|
@patch("mission.tasks.notify_mission_speech.delay")
|
||||||
|
@patch("mission.handlers.enqueue_notification_event")
|
||||||
|
def test_mission_created_speech_requires_category_enabled(self, mock_enqueue, mock_speech_delay):
|
||||||
|
with self.captureOnCommitCallbacks(execute=True):
|
||||||
|
create_mission(creator=self.creator, description="不播报任务")
|
||||||
|
|
||||||
|
mock_enqueue.assert_called_once()
|
||||||
|
mock_speech_delay.assert_not_called()
|
||||||
|
|
||||||
|
@patch("mission.tasks.notify_mission_speech.delay")
|
||||||
|
@patch("mission.handlers.enqueue_notification_event")
|
||||||
|
def test_mission_created_speech_enqueues_when_category_enabled(self, mock_enqueue, mock_speech_delay):
|
||||||
|
self.default_category.speech_enabled = True
|
||||||
|
self.default_category.save(update_fields=["speech_enabled", "updated_at"])
|
||||||
|
|
||||||
|
with self.captureOnCommitCallbacks(execute=True):
|
||||||
|
mission = create_mission(creator=self.creator, description="需要播报任务")
|
||||||
|
|
||||||
|
mock_enqueue.assert_called_once()
|
||||||
|
mock_speech_delay.assert_called_once()
|
||||||
|
self.assertIn(str(mission.description), mock_speech_delay.call_args.kwargs["text"])
|
||||||
|
|
||||||
@patch("mission.handlers.enqueue_notification_event")
|
@patch("mission.handlers.enqueue_notification_event")
|
||||||
def test_mission_created_handler_applies_category_payload_processor(self, mock_enqueue):
|
def test_mission_created_handler_applies_category_payload_processor(self, mock_enqueue):
|
||||||
self.default_category.payload_processor = "structured_description_v1"
|
self.default_category.payload_processor = "structured_description_v1"
|
||||||
|
|||||||
Reference in New Issue
Block a user