From 51707002340fe0ba6b57634aa2668a89f95eeb86 Mon Sep 17 00:00:00 2001 From: colaftc Date: Wed, 1 Jul 2026 11:51:13 +0800 Subject: [PATCH] feat: big --- api_v2/test_cost_api.py | 61 +++++++ api_v2/test_mission_api.py | 8 +- api_v2/views/cost.py | 72 ++++++-- api_v2/views/mission.py | 6 + business/notifications.py | 40 +++++ business/pre_order_services.py | 3 + business/services.py | 10 ++ business/tasks.py | 21 +++ cost/admin.py | 5 +- .../0002_costentry_amount_formula_fields.py | 33 ++++ cost/models.py | 67 ++++++- cost/services.py | 11 +- cost/tests/test_models.py | 52 +++++- cost/tests/test_services.py | 54 ++++++ docs/api_v1_plate_order_detail_2026-06-30.md | 170 ++++++++++++++++++ docs/api_v2_mission_api.md | 8 +- docs/cost/api.md | 79 +++++++- docs/cost/design.md | 30 +++- docs/好布业金额计算报告.md | 118 ++++++++++++ flower/settings.py | 6 +- flower/utils/speech.py | 8 +- mission/admin.py | 4 +- mission/handlers.py | 2 + .../0008_missioncategory_speech_enabled.py | 16 ++ mission/models.py | 1 + mission/notifications.py | 36 ++++ mission/tasks.py | 27 +++ mission/tests.py | 22 +++ 28 files changed, 933 insertions(+), 37 deletions(-) create mode 100644 business/notifications.py create mode 100644 cost/migrations/0002_costentry_amount_formula_fields.py create mode 100644 docs/api_v1_plate_order_detail_2026-06-30.md create mode 100644 docs/好布业金额计算报告.md create mode 100644 mission/migrations/0008_missioncategory_speech_enabled.py create mode 100644 mission/notifications.py create mode 100644 mission/tasks.py diff --git a/api_v2/test_cost_api.py b/api_v2/test_cost_api.py index 0bb9f2f..c02841b 100644 --- a/api_v2/test_cost_api.py +++ b/api_v2/test_cost_api.py @@ -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', '水费') diff --git a/api_v2/test_mission_api.py b/api_v2/test_mission_api.py index 48e6da6..9e4118a 100644 --- a/api_v2/test_mission_api.py +++ b/api_v2/test_mission_api.py @@ -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) diff --git a/api_v2/views/cost.py b/api_v2/views/cost.py index 80ef889..a482261 100644 --- a/api_v2/views/cost.py +++ b/api_v2/views/cost.py @@ -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,18 +243,24 @@ class CostEntryListCreateView(APIView): merchant_id=employee.merchant_id, ) - entry = cost_models.CostEntry.objects.create( - merchant=employee.merchant, - category=category, - amount=data['amount'], - occurred_at=data['occurred_at'], - operator=employee, - image1=data.get('image1'), - image2=data.get('image2'), - source_module=data.get('source_module', ''), - source_id=data.get('source_id', ''), - remarks=data.get('remarks', ''), - ) + try: + entry = cost_services.create_cost_entry( + merchant=employee.merchant, + category=category, + 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'), + source_module=data.get('source_module', ''), + 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: - entry.save(update_fields=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)) diff --git a/api_v2/views/mission.py b/api_v2/views/mission.py index 73b441b..0028d23 100644 --- a/api_v2/views/mission.py +++ b/api_v2/views/mission.py @@ -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"]) diff --git a/business/notifications.py b/business/notifications.py new file mode 100644 index 0000000..338e247 --- /dev/null +++ b/business/notifications.py @@ -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', '')) diff --git a/business/pre_order_services.py b/business/pre_order_services.py index 6dc7a02..3a88638 100644 --- a/business/pre_order_services.py +++ b/business/pre_order_services.py @@ -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 diff --git a/business/services.py b/business/services.py index abe1c2f..899f69f 100644 --- a/business/services.py +++ b/business/services.py @@ -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 diff --git a/business/tasks.py b/business/tasks.py index ed2498a..799e9a0 100644 --- a/business/tasks.py +++ b/business/tasks.py @@ -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), + } + diff --git a/cost/admin.py b/cost/admin.py index 963e2a5..10b02f6 100644 --- a/cost/admin.py +++ b/cost/admin.py @@ -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') diff --git a/cost/migrations/0002_costentry_amount_formula_fields.py b/cost/migrations/0002_costentry_amount_formula_fields.py new file mode 100644 index 0000000..08eb6ce --- /dev/null +++ b/cost/migrations/0002_costentry_amount_formula_fields.py @@ -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='单位名称'), + ), + ] diff --git a/cost/models.py b/cost/models.py index 87ddec4..856f72b 100644 --- a/cost/models.py +++ b/cost/models.py @@ -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 = '' diff --git a/cost/services.py b/cost/services.py index c3f76a9..5b4f3b8 100644 --- a/cost/services.py +++ b/cost/services.py @@ -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, diff --git a/cost/tests/test_models.py b/cost/tests/test_models.py index ac07f2f..bb7281d 100644 --- a/cost/tests/test_models.py +++ b/cost/tests/test_models.py @@ -142,4 +142,54 @@ class CostEntryModelTests(TestCase): occurred_at=date(2026, 6, 1), ) self.assertFalse(bool(entry.image1)) - self.assertFalse(bool(entry.image2)) \ No newline at end of file + 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) diff --git a/cost/tests/test_services.py b/cost/tests/test_services.py index e8092ac..69812f5 100644 --- a/cost/tests/test_services.py +++ b/cost/tests/test_services.py @@ -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: diff --git a/docs/api_v1_plate_order_detail_2026-06-30.md b/docs/api_v1_plate_order_detail_2026-06-30.md new file mode 100644 index 0000000..07daae4 --- /dev/null +++ b/docs/api_v1_plate_order_detail_2026-06-30.md @@ -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" +} +``` + diff --git a/docs/api_v2_mission_api.md b/docs/api_v2_mission_api.md index 3a8583d..994cd3c 100644 --- a/docs/api_v2_mission_api.md +++ b/docs/api_v2_mission_api.md @@ -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` diff --git a/docs/cost/api.md b/docs/cost/api.md index cb711ad..ff6abb8 100644 --- a/docs/cost/api.md +++ b/docs/cost/api.md @@ -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 删除支出明细 ``` diff --git a/docs/cost/design.md b/docs/cost/design.md index b084fde..bb5ef8d 100644 --- a/docs/cost/design.md +++ b/docs/cost/design.md @@ -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) @@ -177,4 +197,6 @@ class PrintingCostProvider: | 4 | `CostCategory.unique_key` 用于 Port 匹配 | 比按 `name` 匹配更稳定,避免重名/改名问题 | | 5 | `CostEntry` 带两个 `ImageField` | 用户要求:一个用于凭证图片,一个预留备用 | | 6 | 汇总 API 按 `start/end` 时间段 + `group by category` | 用户指定的统计方式 | -| 7 | 第一版不做 Provider 注册表 | Provider 暂时只有一个调用入口 `collect_from_provider()`,后续可扩展为注册表模式 | \ No newline at end of file +| 7 | 第一版不做 Provider 注册表 | Provider 暂时只有一个调用入口 `collect_from_provider()`,后续可扩展为注册表模式 | +| 8 | `amount` 固定为最终统计金额 | 兼容普通金额支出与倍数型支出,避免统计层判断 `amount` 的双重语义 | +| 9 | 金额公式在 `CostEntry.save()` 兜底计算 | 保证 create/update 经实例保存时都能重算 `amount`,service 层作为推荐业务入口 | diff --git a/docs/好布业金额计算报告.md b/docs/好布业金额计算报告.md new file mode 100644 index 0000000..134f5f0 --- /dev/null +++ b/docs/好布业金额计算报告.md @@ -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 系统在金额计算中,按原始小数完成「数量 × 单价」后,对乘积统一四舍五入(逢五进一)为整数元,输入阶段不舍入,最终结算单位为元级整数金额。 diff --git a/flower/settings.py b/flower/settings.py index 5ba8672..c017f25 100644 --- a/flower/settings.py +++ b/flower/settings.py @@ -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 外部财务同步 diff --git a/flower/utils/speech.py b/flower/utils/speech.py index 71b3c8c..b7ca76e 100644 --- a/flower/utils/speech.py +++ b/flower/utils/speech.py @@ -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', ) diff --git a/mission/admin.py b/mission/admin.py index 52f6551..f464e76 100644 --- a/mission/admin.py +++ b/mission/admin.py @@ -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"] diff --git a/mission/handlers.py b/mission/handlers.py index 37596bd..7a157ee 100644 --- a/mission/handlers.py +++ b/mission/handlers.py @@ -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): diff --git a/mission/migrations/0008_missioncategory_speech_enabled.py b/mission/migrations/0008_missioncategory_speech_enabled.py new file mode 100644 index 0000000..75ab69c --- /dev/null +++ b/mission/migrations/0008_missioncategory_speech_enabled.py @@ -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="是否播报"), + ), + ] diff --git a/mission/models.py b/mission/models.py index 7e5d55e..161ba08 100644 --- a/mission/models.py +++ b/mission/models.py @@ -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 diff --git a/mission/notifications.py b/mission/notifications.py new file mode 100644 index 0000000..42a5ca6 --- /dev/null +++ b/mission/notifications.py @@ -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}任务" diff --git a/mission/tasks.py b/mission/tasks.py new file mode 100644 index 0000000..e69efee --- /dev/null +++ b/mission/tasks.py @@ -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), + } diff --git a/mission/tests.py b/mission/tests.py index 2dd8615..27c5af5 100644 --- a/mission/tests.py +++ b/mission/tests.py @@ -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"