1
0
forked from erp-dev/erp

feat: big

This commit is contained in:
2026-07-01 11:51:13 +08:00
parent 7c9b77afb0
commit 5170700234
28 changed files with 933 additions and 37 deletions

View File

@@ -206,6 +206,32 @@ class CostAPITest(TestCase):
self.assertEqual(resp.data['category_name'], '电费')
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):
resp = self.client.post('/api/v2/cost-entries/', {
'category_id': 99999,
@@ -275,6 +301,41 @@ class CostAPITest(TestCase):
self.assertEqual(resp.data['amount'], '999.00')
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):
cat1 = self._create_category('elec', '电费')
cat2 = self._create_category('water', '水费')

View File

@@ -542,29 +542,33 @@ class MissionV2APITest(TestCase):
["通用", "跟进"],
)
self.assertEqual(list_resp.data[0]["payload_processor"], "")
self.assertFalse(list_resp.data[0]["speech_enabled"])
create_resp = self.client.post(
"/api/v2/mission-categories/",
{"name": "售后", "payload_processor": "structured_description_v1"},
{"name": "售后", "payload_processor": "structured_description_v1", "speech_enabled": True},
format="json",
)
self.assertEqual(create_resp.status_code, 201)
category_id = create_resp.data["id"]
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}/")
self.assertEqual(detail_resp.status_code, 200)
self.assertEqual(detail_resp.data["name"], "售后")
self.assertEqual(detail_resp.data["payload_processor"], "structured_description_v1")
self.assertTrue(detail_resp.data["speech_enabled"])
patch_resp = self.client.patch(
f"/api/v2/mission-categories/{category_id}/",
{"name": "售后跟进", "payload_processor": ""},
{"name": "售后跟进", "payload_processor": "", "speech_enabled": False},
format="json",
)
self.assertEqual(patch_resp.status_code, 200)
self.assertEqual(patch_resp.data["name"], "售后跟进")
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}/")
self.assertEqual(delete_resp.status_code, 204)

View File

@@ -1,5 +1,6 @@
from datetime import date
from django.core.exceptions import ValidationError as DjangoValidationError
from django.shortcuts import get_object_or_404
from rest_framework import permissions, serializers, status
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_name': entry.category.name if entry.category_id else '',
'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(),
'operator_id': entry.operator_id,
'image1': _image_url(entry.image1),
@@ -81,7 +85,16 @@ class CategoryUpdateSerializer(serializers.Serializer):
class EntryWriteSerializer(serializers.Serializer):
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()
image1 = 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='')
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):
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)
image1 = 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,
)
entry = cost_models.CostEntry.objects.create(
try:
entry = cost_services.create_cost_entry(
merchant=employee.merchant,
category=category,
amount=data['amount'],
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,
image1=data.get('image1'),
image2=data.get('image2'),
@@ -222,6 +259,8 @@ class CostEntryListCreateView(APIView):
source_id=data.get('source_id', ''),
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)
@@ -261,7 +300,10 @@ class CostEntryDetailView(APIView):
setattr(entry, field, value)
update_fields.append(field)
if update_fields:
try:
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()
return Response(_entry_payload(entry))

View File

@@ -108,6 +108,7 @@ class MissionUrgentSerializer(serializers.Serializer):
class MissionCategoryWriteSerializer(serializers.Serializer):
name = serializers.CharField(allow_blank=False, max_length=50)
speech_enabled = serializers.BooleanField(required=False)
payload_processor = serializers.ChoiceField(
choices=mission_models.MissionPayloadProcessorEnum.choices,
required=False,
@@ -257,6 +258,7 @@ class MissionCategorySerializer(serializers.ModelSerializer):
"merchant",
"name",
"payload_processor",
"speech_enabled",
"created_at",
"updated_at",
]
@@ -409,6 +411,7 @@ class MissionCategoryListCreateView(APIView):
merchant=employee.merchant,
name=serializer.validated_data["name"],
payload_processor=serializer.validated_data.get("payload_processor", ""),
speech_enabled=serializer.validated_data.get("speech_enabled", False),
)
except IntegrityError:
return Response({"name": ["分类名称已存在"]}, status=status.HTTP_400_BAD_REQUEST)
@@ -442,6 +445,9 @@ class MissionCategoryDetailView(APIView):
if "payload_processor" in serializer.validated_data:
category.payload_processor = serializer.validated_data["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:
try:
category.save(update_fields=[*update_fields, "updated_at"])

40
business/notifications.py Normal file
View 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', ''))

View File

@@ -13,6 +13,7 @@ from stock import models as stock_models
from . import models
from . import services as business_services
from .notifications import enqueue_business_order_speech
def render_pre_sales_order_created_markdown(
@@ -304,6 +305,7 @@ def create_pre_sales_order(
transaction.on_commit(_send_created_signal)
pre_sales_order.refresh_from_db()
enqueue_business_order_speech(order=pre_sales_order, order_label='预销售单', action_label='创建')
return pre_sales_order
@@ -580,6 +582,7 @@ def create_pre_purchase_order(
)
pre_purchase_order.refresh_from_db()
enqueue_business_order_speech(order=pre_purchase_order, order_label='预采购单', action_label='创建')
return pre_purchase_order

View File

@@ -23,6 +23,9 @@ from .tasks import (
create_purchase_return_order_stock_entries,
create_sales_return_order_stock_entries,
)
from .notifications import enqueue_business_order_speech
class BalanceService:
@staticmethod
def adjust_supplier_balance(
@@ -317,6 +320,7 @@ def create_purchase_order(
models.PurchaseOrderItem.objects.bulk_create(bulk_objects)
purchase_order.refresh_from_db()
enqueue_business_order_speech(order=purchase_order, order_label='采购单', action_label='创建')
return purchase_order
@@ -462,6 +466,7 @@ def create_sales_order(
models.SalesOrderItem.objects.bulk_create(bulk_objects)
sales_order.refresh_from_db()
enqueue_business_order_speech(order=sales_order, order_label='销售单', action_label='创建')
return sales_order
@@ -613,6 +618,7 @@ def create_purchase_return_order(
models.PurchaseReturnOrderItem.objects.bulk_create(bulk_objects)
return_order.refresh_from_db()
enqueue_business_order_speech(order=return_order, order_label='采购退货单', action_label='创建')
return return_order
@@ -766,6 +772,7 @@ def create_sales_return_order(
models.SalesReturnOrderItem.objects.bulk_create(bulk_objects)
return_order.refresh_from_db()
enqueue_business_order_speech(order=return_order, order_label='销售退货单', action_label='创建')
return return_order
@@ -883,6 +890,7 @@ def create_payment_order(
discount_amount=normalized_discount,
)
payment_order.refresh_from_db()
enqueue_business_order_speech(order=payment_order, order_label='付款单', action_label='创建')
return payment_order
@@ -921,6 +929,7 @@ def create_receipt_order(
discount_amount=normalized_discount,
)
receipt_order.refresh_from_db()
enqueue_business_order_speech(order=receipt_order, order_label='收款单', action_label='创建')
return receipt_order
@@ -1687,6 +1696,7 @@ def _cancel_order_impl(
locked.status = cancelled_status
locked.save(update_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

View File

@@ -157,3 +157,24 @@ def create_sales_return_order_stock_entries(
payload['task_id'] = self.request.id
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),
}

View File

@@ -14,8 +14,9 @@ class CostCategoryAdmin(admin.ModelAdmin):
@admin.register(models.CostEntry)
class CostEntryAdmin(admin.ModelAdmin):
list_display = (
'id', 'merchant', 'category', 'amount', 'occurred_at',
'operator', 'source_module', 'source_id', 'created_at',
'id', 'merchant', 'category', 'amount', 'unit_amount', 'quantity',
'unit_name', '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')

View 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='单位名称'),
),
]

View File

@@ -2,9 +2,10 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from decimal import Decimal, ROUND_HALF_UP
from typing import Protocol, runtime_checkable
from django.core.exceptions import ValidationError
from django.db import models
from flower.common import ModelBase
@@ -44,6 +45,8 @@ class CostCategory(ModelBase):
class CostEntry(ModelBase):
"""支出明细"""
AMOUNT_QUANT = Decimal('0.01')
merchant = models.ForeignKey(
'basic_info.Merchant',
on_delete=models.PROTECT,
@@ -56,7 +59,27 @@ class CostEntry(ModelBase):
related_name='entries',
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='发生日期')
operator = models.ForeignKey(
'basic_info.Employee',
@@ -84,6 +107,41 @@ class CostEntry(ModelBase):
def __str__(self):
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_name: str
amount: Decimal
amount: Decimal | None
occurred_at: date
source_module: str
source_id: str
unit_amount: Decimal | None = None
quantity: Decimal | None = None
unit_name: str = ''
remarks: str = ''

View File

@@ -51,11 +51,14 @@ def create_cost_entry(
*,
merchant: Merchant,
category: cost_models.CostCategory,
amount: Decimal,
occurred_at: date,
amount: Decimal | None = None,
operator: Employee | None = None,
image1=None,
image2=None,
unit_amount: Decimal | None = None,
quantity: Decimal | None = None,
unit_name: str = '',
source_module: str = '',
source_id: str = '',
remarks: str = '',
@@ -70,6 +73,9 @@ def create_cost_entry(
merchant=merchant,
category=category,
amount=amount,
unit_amount=unit_amount,
quantity=quantity,
unit_name=unit_name or '',
occurred_at=occurred_at,
operator=operator,
image1=image1,
@@ -122,6 +128,9 @@ def collect_from_provider(
merchant=merchant,
category=category,
amount=entry.amount,
unit_amount=entry.unit_amount,
quantity=entry.quantity,
unit_name=entry.unit_name,
occurred_at=entry.occurred_at,
operator=None, # provider 采集的记录没有经办人
source_module=entry.source_module,

View File

@@ -143,3 +143,53 @@ class CostEntryModelTests(TestCase):
)
self.assertFalse(bool(entry.image1))
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)

View File

@@ -171,6 +171,33 @@ class CreateCostEntryTests(TestCase):
self.assertEqual(entry.source_id, '')
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:
"""测试用 Provider"""
@@ -222,6 +249,33 @@ class CollectFromProviderTests(TestCase):
self.assertEqual(cost_models.CostEntry.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):
"""Provider 返回非 list 抛 TypeError"""
class BadProvider:

View 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"
}
```

View File

@@ -96,6 +96,8 @@
"id": 1,
"merchant": 10,
"name": "通用",
"payload_processor": "",
"speech_enabled": false,
"created_at": "2026-04-10T12:00:00+08:00",
"updated_at": "2026-04-10T12:00:00+08:00"
}
@@ -287,6 +289,7 @@
| `merchant` | int | 所属商户 ID |
| `name` | string | 分类名称 |
| `payload_processor` | string | payload 增强器标识,未启用时为空字符串 |
| `speech_enabled` | boolean | 该分类创建任务时是否触发语音播报;默认 `false` |
| `created_at` | datetime | 创建时间 |
| `updated_at` | datetime | 更新时间 |
@@ -301,13 +304,15 @@
|------|------|------|------|
| `name` | string | 是 | 分类名称,同商户下唯一 |
| `payload_processor` | string | 否 | payload 增强器标识;当前可选值:`structured_description_v1` |
| `speech_enabled` | boolean | 否 | 是否启用该分类的任务创建语音播报;默认 `false` |
请求示例:
```json
{
"name": "售后",
"payload_processor": "structured_description_v1"
"payload_processor": "structured_description_v1",
"speech_enabled": true
}
```
@@ -331,6 +336,7 @@
|------|------|------|
| `name` | string | 分类名称,同商户下唯一 |
| `payload_processor` | string | payload 增强器标识;传空字符串表示清空 |
| `speech_enabled` | boolean | 是否启用该分类的任务创建语音播报 |
成功响应:`MissionCategory`

View File

@@ -2,6 +2,29 @@
本文档面向前端,描述 `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`
@@ -153,6 +176,9 @@ GET /api/v2/cost-entries/
"category_id": 1,
"category_name": "电费",
"amount": "350.00",
"unit_amount": null,
"quantity": null,
"unit_name": "",
"occurred_at": "2026-06-01",
"operator_id": 12,
"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` |
| `operator_id` | int | 经办人 ID自动从当前登录用户获取 |
| `image1` | string | 凭证图片 URL七牛云 CDN无则为空字符串 |
@@ -191,7 +220,10 @@ Content-Type: `multipart/form-data`(支持图片上传)或 `application/json
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `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` |
| `image1` | file | 否 | 凭证图片 |
| `image2` | file | 否 | 备用凭证图片 |
@@ -199,7 +231,7 @@ Content-Type: `multipart/form-data`(支持图片上传)或 `application/json
| `source_id` | string | 否 | 来源记录 ID |
| `remarks` | string | 否 | 备注 |
请求示例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`:返回创建的支出明细对象。
错误响应 `400`:校验失败。
@@ -238,7 +285,10 @@ Content-Type: `multipart/form-data` 或 `application/json`
| 字段 | 类型 | 说明 |
|------|------|------|
| `category_id` | int | 切换类目 |
| `amount` | decimal | 金额 |
| `amount` | decimal/null | 普通支出金额;倍数型支出若公式字段存在,会被后端公式结果覆盖 |
| `unit_amount` | decimal/null | **新增**。单价 / 基数金额;传 `null` 可清除公式字段 |
| `quantity` | decimal/null | **新增**。数量 / 倍数;传 `null` 可清除公式字段 |
| `unit_name` | string | **新增**。单位名称;可传空字符串清空 |
| `occurred_at` | date | 发生日期 |
| `image1` | file | 凭证图片 |
| `image2` | file | 备用凭证图片 |
@@ -248,6 +298,27 @@ Content-Type: `multipart/form-data` 或 `application/json`
成功响应 `200`:返回更新后的支出明细对象。
修改倍数型支出数量示例:
```json
{
"quantity": "4"
}
```
后端会按已有 `unit_amount * quantity` 重算并返回新的 `amount`
将倍数型支出改回普通支出示例:
```json
{
"amount": "550.00",
"unit_amount": null,
"quantity": null,
"unit_name": ""
}
```
### 2.5 删除支出明细
```

View File

@@ -58,7 +58,10 @@ api_v2/views/cost.py # API View不放在 cost 内部)
| `id` | BigAutoField (PK) | |
| `merchant` | FK → Merchant | 所属商户 |
| `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 | 发生日期 |
| `operator` | FK → Employee | 经办人 |
| `image1` | ImageField (null) | 凭证图片 |
@@ -80,10 +83,13 @@ api_v2/views/cost.py # API View不放在 cost 内部)
class CostEntryInput:
category_key: str # 类目标识键,用于匹配 CostCategory.unique_key
category_name: str # 类目显示名(匹配不到时用此名自动创建)
amount: Decimal
amount: Decimal | None # 最终金额;倍数型支出可传 None由 unit_amount * quantity 计算
occurred_at: date
source_module: str
source_id: str
unit_amount: Decimal | None = None
quantity: Decimal | None = None
unit_name: str = ''
remarks: str = ''
```
@@ -143,10 +149,24 @@ class PrintingCostProvider:
| 函数 | 说明 |
|------|------|
| `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 采集成本数据 |
| `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)
@@ -178,3 +198,5 @@ class PrintingCostProvider:
| 5 | `CostEntry` 带两个 `ImageField` | 用户要求:一个用于凭证图片,一个预留备用 |
| 6 | 汇总 API 按 `start/end` 时间段 + `group by category` | 用户指定的统计方式 |
| 7 | 第一版不做 Provider 注册表 | Provider 暂时只有一个调用入口 `collect_from_provider()`,后续可扩展为注册表模式 |
| 8 | `amount` 固定为最终统计金额 | 兼容普通金额支出与倍数型支出,避免统计层判断 `amount` 的双重语义 |
| 9 | 金额公式在 `CostEntry.save()` 兜底计算 | 保证 create/update 经实例保存时都能重算 `amount`service 层作为推荐业务入口 |

View 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 系统在金额计算中,按原始小数完成「数量 × 单价」后,对乘积统一四舍五入(逢五进一)为整数元,输入阶段不舍入,最终结算单位为元级整数金额。

View File

@@ -92,8 +92,12 @@ MESSAGE_API_DEFAULT_NEWS_IMAGE_URL = env(
'MESSAGE_API_DEFAULT_NEWS_IMAGE_URL',
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
BUSINESS_ORDER_SPEECH_NOTIFY_ENABLED = True
MISSION_SPEECH_NOTIFY_ENABLED = True
AGENT_ACCESS_KEY = env('AGENT_ACCESS_KEY', default='')
# HaoBuYe 外部财务同步

View File

@@ -31,6 +31,8 @@ def play_speech(
content = (text or '').strip()
if not content:
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()
if not url:
@@ -38,10 +40,14 @@ def play_speech(
payload = {'text': content}
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(
url=url,
data=data,
headers={'Content-Type': 'application/json'},
headers=headers,
method='POST',
)

View File

@@ -5,8 +5,8 @@ from mission.models import Mission, MissionCategory, MissionParticipant, Mission
@admin.register(MissionCategory)
class MissionCategoryAdmin(admin.ModelAdmin):
list_display = ["id", "merchant", "name", "payload_processor", "created_at"]
list_filter = ["merchant", "payload_processor", "created_at"]
list_display = ["id", "merchant", "name", "payload_processor", "speech_enabled", "created_at"]
list_filter = ["merchant", "payload_processor", "speech_enabled", "created_at"]
search_fields = ["name"]
readonly_fields = ["created_at", "updated_at"]

View File

@@ -1,5 +1,6 @@
import logging
from mission.notifications import enqueue_mission_created_speech
from mission.payload_processors import apply_mission_payload_processor
from notifier.models import NotificationEventKeyEnum
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,
payload=payload,
)
enqueue_mission_created_speech(mission=instance)
def on_mission_replied(sender, instance, mission=None, responder=None, **kwargs):

View 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="是否播报"),
),
]

View File

@@ -28,6 +28,7 @@ class MissionCategory(ModelBase):
choices=MissionPayloadProcessorEnum.choices,
verbose_name="payload 增强器",
)
speech_enabled = models.BooleanField(default=False, verbose_name="是否播报")
def __str__(self):
return self.name

36
mission/notifications.py Normal file
View 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
View 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),
}

View File

@@ -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["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")
def test_mission_created_handler_applies_category_payload_processor(self, mock_enqueue):
self.default_category.payload_processor = "structured_description_v1"