diff --git a/api_v1/test_pre_sales_order_api.py b/api_v1/test_pre_sales_order_api.py new file mode 100644 index 0000000..eb9d46b --- /dev/null +++ b/api_v1/test_pre_sales_order_api.py @@ -0,0 +1,123 @@ +import copy + +from django.contrib.auth import get_user_model +from django.test import TestCase, override_settings +from rest_framework import status +from rest_framework.test import APIClient + +from basic_info.models import ( + Merchant, + MerchantTypeEnum, + Customer, + WareHouse, + WareHouseModeEnum, + ProductCategory, + Product, + ProductUnitEnum, + Employee, + EmployeeStatusEnum, +) +from business import models as business_models + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CELERY_TASK_EAGER_PROPAGATES=True, +) +class PreSalesOrderAPITestCase(TestCase): + def setUp(self): + self.merchant = Merchant.objects.create(name='预销售商户', type=MerchantTypeEnum.FACTORY) + self.customer = Customer.objects.create( + merchant=self.merchant, + name='预销售客户', + created_by=None, + ) + self.warehouse = WareHouse.objects.create( + merchant=self.merchant, + name='预销售仓库', + mode=WareHouseModeEnum.UNRESTRICTED, + ) + category = ProductCategory.objects.create( + merchant=self.merchant, + name='预销售品类', + product_prefix='PRE', + ) + self.product = Product.objects.create( + merchant=self.merchant, + category=category, + name='预销售产品', + human_id='PRE-001', + unit=ProductUnitEnum.METER, + ) + + User = get_user_model() + self.user = User.objects.create_user(username='pre_sales_user', password='pass123') + self.employee = Employee.objects.create( + merchant=self.merchant, + sys_user=self.user, + name='预销售员', + status=EmployeeStatusEnum.ACTIVE, + ) + + self.client = APIClient() + self.client.force_authenticate(user=self.user) + + self.payload = { + 'customer': self.customer.id, + 'warehouse': self.warehouse.id, + 'kind': business_models.SalesOrderKindEnum.WHOLESALE, + 'remarks': '预销售单备注', + 'items': [ + { + 'product_id': self.product.id, + 'quantity': '12.5', + 'unit': '米', + 'order_quantity': 7, + 'remarks': '明细备注', + } + ], + } + + def _create(self, payload=None) -> int: + resp = self.client.post('/api/v1/pre-sales-orders/', payload or self.payload, format='json') + self.assertEqual(resp.status_code, status.HTTP_201_CREATED) + return resp.data['id'] + + def test_create_pre_sales_order_success(self): + resp = self.client.post('/api/v1/pre-sales-orders/', self.payload, format='json') + self.assertEqual(resp.status_code, status.HTTP_201_CREATED) + self.assertIn('id', resp.data) + self.assertIn('human_id', resp.data) + self.assertEqual(resp.data['customer'], self.customer.id) + self.assertEqual(resp.data['warehouse'], self.warehouse.id) + self.assertEqual(len(resp.data['items']), 1) + + def test_list_pre_sales_orders(self): + self._create() + resp = self.client.get('/api/v1/pre-sales-orders/') + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertIn('results', resp.data) + self.assertGreaterEqual(len(resp.data['results']), 1) + + def test_get_detail(self): + order_id = self._create() + resp = self.client.get(f'/api/v1/pre-sales-orders/{order_id}/') + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertEqual(resp.data['id'], order_id) + self.assertIn('items', resp.data) + + def test_update_pre_sales_order(self): + order_id = self._create() + payload = copy.deepcopy(self.payload) + payload['remarks'] = '更新备注' + payload['items'][0]['quantity'] = '20' + resp = self.client.put(f'/api/v1/pre-sales-orders/{order_id}/', payload, format='json') + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertEqual(resp.data['remarks'], '更新备注') + self.assertEqual(str(resp.data['items'][0]['quantity']), '20.00') + + def test_delete_pre_sales_order(self): + order_id = self._create() + resp = self.client.delete(f'/api/v1/pre-sales-orders/{order_id}/') + self.assertEqual(resp.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse(business_models.PreSalesOrder.objects.filter(id=order_id).exists()) diff --git a/api_v1/urls.py b/api_v1/urls.py index df616e8..b050667 100644 --- a/api_v1/urls.py +++ b/api_v1/urls.py @@ -19,6 +19,7 @@ from .views.business.balance import views as balance_views from .views.business.purchase_return import views as purchase_return_views from .views.business.sales_return import views as sales_return_views from .views.business.statements import views as statement_views +from .views.business.pre_sales import views as pre_sales_views from .views.stock_change_views.snapshot import StockSnapshotListView from .views.printing.views import PrintingOrderViewSet, PrintingJobViewSet, PlateOrderViewSet from .views.upload import UploadFileViewSet @@ -87,6 +88,8 @@ urlpatterns = [ path('sales-return-orders/', sales_return_views.SalesReturnOrderView.as_view(), name='sales_return_orders'), path('sales-return-orders//', sales_return_views.SalesReturnOrderDetailView.as_view(), name='sales_return_order_detail'), path('sales-return-orders//review/', sales_return_views.SalesReturnOrderReviewView.as_view(), name='sales_return_order_review'), + path('pre-sales-orders/', pre_sales_views.PreSalesOrderView.as_view(), name='pre_sales_orders'), + path('pre-sales-orders//', pre_sales_views.PreSalesOrderDetailView.as_view(), name='pre_sales_order_detail'), path('payment-orders/', payment_views.PaymentOrderView.as_view(), name='payment_orders'), path('payment-orders//review/', payment_views.PaymentOrderReviewView.as_view(), name='payment_order_review'), path('receipt-orders/', receipt_views.ReceiptOrderView.as_view(), name='receipt_orders'), diff --git a/api_v1/views/business/pre_sales/__init__.py b/api_v1/views/business/pre_sales/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api_v1/views/business/pre_sales/views.py b/api_v1/views/business/pre_sales/views.py new file mode 100644 index 0000000..4ee7fa9 --- /dev/null +++ b/api_v1/views/business/pre_sales/views.py @@ -0,0 +1,174 @@ +from rest_framework import status, views, serializers, pagination +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from api_v1.views.stock_change_views.mixins import StockChangeViewMixin +from business import models as business_models +from business import pre_order_services + + +class PreSalesOrderItemSerializer(serializers.ModelSerializer): + class Meta: + model = business_models.PreSalesOrderItem + fields = [ + 'id', + 'product_id', + 'product_name', + 'color', + 'quantity', + 'unit', + 'spec', + 'quantity_of_rolls', + 'num_of_rolls', + 'order_quantity', + 'remarks', + 'created_at', + 'updated_at', + ] + read_only_fields = ['id', 'created_at', 'updated_at'] + + +class PreSalesOrderSerializer(serializers.ModelSerializer): + human_id = serializers.CharField(read_only=True) + merchant_name = serializers.CharField(source='merchant.name', read_only=True) + customer_name = serializers.CharField(source='customer.name', read_only=True) + warehouse_name = serializers.CharField(source='warehouse.name', read_only=True) + created_by_name = serializers.CharField(source='created_by.name', read_only=True) + items = PreSalesOrderItemSerializer(many=True, read_only=True) + + class Meta: + model = business_models.PreSalesOrder + fields = [ + 'id', + 'human_id', + 'merchant', + 'merchant_name', + 'customer', + 'customer_name', + 'warehouse', + 'warehouse_name', + 'created_by', + 'created_by_name', + 'kind', + 'remarks', + 'created_at', + 'updated_at', + 'items', + ] + read_only_fields = [ + 'id', + 'human_id', + 'created_at', + 'updated_at', + 'merchant_name', + 'customer_name', + 'warehouse_name', + 'created_by_name', + 'items', + ] + + +class PreSalesOrderPagination(pagination.LimitOffsetPagination): + default_limit = 20 + max_limit = 100 + + +class PreSalesOrderView(StockChangeViewMixin, views.APIView): + """预销售单查询与创建""" + + permission_classes = [IsAuthenticated] + pagination_class = PreSalesOrderPagination + + def get(self, request): + if not self.check_employee_permission(request): + return self.permission_error_response('无权限访问') + + merchant = request.user.employee.merchant + queryset = pre_order_services.list_pre_sales_orders(merchant=merchant) + + paginator = self.pagination_class() + page = paginator.paginate_queryset(queryset.order_by('-created_at'), request, view=self) + serializer = PreSalesOrderSerializer(page, many=True) + return paginator.get_paginated_response(serializer.data) + + def post(self, request): + if not self.check_employee_permission(request): + return self.permission_error_response('无权限访问') + + merchant = request.user.employee.merchant + employee = request.user.employee + data = request.data or {} + + try: + pre_sales_order = pre_order_services.create_pre_sales_order( + merchant=merchant, + customer_id=data.get('customer') or data.get('customer_id'), + warehouse_id=data.get('warehouse') or data.get('warehouse_id'), + created_by=employee, + kind=data.get('kind'), + items=data.get('items'), + remarks=data.get('remarks', ''), + ) + except ValueError as exc: + return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST) + + return Response(PreSalesOrderSerializer(pre_sales_order).data, status=status.HTTP_201_CREATED) + + +class PreSalesOrderDetailView(StockChangeViewMixin, views.APIView): + permission_classes = [IsAuthenticated] + + def _get_order(self, request, pk: int): + if not self.check_employee_permission(request): + return None, self.permission_error_response('无权限访问') + + merchant = request.user.employee.merchant + try: + order = pre_order_services.get_pre_sales_order( + merchant=merchant, + pre_sales_order_id=pk, + ) + return order, None + except ValueError: + return None, self.not_found_response('预销售单不存在') + + def get(self, request, pk: int): + order, error_response = self._get_order(request, pk) + if error_response: + return error_response + return Response(PreSalesOrderSerializer(order).data, status=status.HTTP_200_OK) + + def put(self, request, pk: int): + return self._update(request, pk) + + def patch(self, request, pk: int): + return self._update(request, pk) + + def _update(self, request, pk: int): + order, error_response = self._get_order(request, pk) + if error_response: + return error_response + + data = request.data or {} + + try: + updated = pre_order_services.update_pre_sales_order( + pre_sales_order=order, + customer_id=data.get('customer') or data.get('customer_id'), + warehouse_id=data.get('warehouse') or data.get('warehouse_id'), + kind=data.get('kind'), + items=data.get('items'), + remarks=data.get('remarks'), + ) + except ValueError as exc: + return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST) + + return Response(PreSalesOrderSerializer(updated).data, status=status.HTTP_200_OK) + + def delete(self, request, pk: int): + order, error_response = self._get_order(request, pk) + if error_response: + return error_response + + pre_order_services.delete_pre_sales_order(pre_sales_order=order) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/business/migrations/0022_presalesorder_presalesorderitem.py b/business/migrations/0022_presalesorder_presalesorderitem.py new file mode 100644 index 0000000..fd41b92 --- /dev/null +++ b/business/migrations/0022_presalesorder_presalesorderitem.py @@ -0,0 +1,56 @@ +# Generated by Django 5.2.7 on 2026-01-31 13:48 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('basic_info', '0024_frontend_page_and_visible_pages'), + ('business', '0021_remove_salesorder_order_quantity'), + ] + + operations = [ + migrations.CreateModel( + name='PreSalesOrder', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('kind', models.IntegerField(choices=[(1, '大货'), (2, '样板')], default=1, verbose_name='预销售单类型')), + ('remarks', models.TextField(blank=True, null=True, verbose_name='备注')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='created_pre_sales_orders', to='basic_info.employee', verbose_name='创建者')), + ('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='pre_sales_orders', to='basic_info.customer', verbose_name='客户')), + ('merchant', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='pre_sales_orders', to='basic_info.merchant', verbose_name='所属商户')), + ('warehouse', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='pre_sales_orders', to='basic_info.warehouse', verbose_name='仓库')), + ], + options={ + 'verbose_name': '预销售单', + 'verbose_name_plural': '预销售单', + }, + ), + migrations.CreateModel( + name='PreSalesOrderItem', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('product_id', models.BigIntegerField(blank=True, null=True, verbose_name='产品ID')), + ('product_name', models.CharField(blank=True, default='', max_length=100, verbose_name='产品名称')), + ('color', models.CharField(blank=True, max_length=50, null=True, verbose_name='颜色')), + ('quantity', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='数量')), + ('unit', models.CharField(blank=True, default='', max_length=50, verbose_name='单位')), + ('spec', models.CharField(blank=True, max_length=100, null=True, verbose_name='规格')), + ('quantity_of_rolls', models.TextField(blank=True, null=True, verbose_name='各条数数量')), + ('num_of_rolls', models.PositiveIntegerField(default=1, verbose_name='条数')), + ('order_quantity', models.IntegerField(blank=True, null=True, verbose_name='下单数量')), + ('remarks', models.TextField(blank=True, null=True, verbose_name='备注')), + ('pre_sales_order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='business.presalesorder', verbose_name='预销售单')), + ], + options={ + 'verbose_name': '预销售单明细', + 'verbose_name_plural': '预销售单明细', + }, + ), + ] diff --git a/business/models.py b/business/models.py index 66efcea..a393a73 100644 --- a/business/models.py +++ b/business/models.py @@ -3,6 +3,7 @@ from __future__ import annotations from decimal import Decimal from typing import List from django.db import models +from django.core.exceptions import ValidationError from flower.common import ModelBase from basic_info import models as basic_info_models @@ -360,6 +361,146 @@ class SalesOrderItem(ModelBase): return round(self.price * self.real_quantity(), 2) +class PreSalesOrder(ModelBase): + """预销售单 + + 预销售单用于在客户下达采购/要货指令时,提前通知仓库进行库存核对与调货。 + + 目前阶段:仅定义 PreSalesOrder 与 PreSalesOrderItem 的一对多关系, + 并关联 customer 与 created_by;其他业务对象(如 Warehouse / StockChangeRecord / StockFreeze 等) + 暂不在此模型中建立关联。 + """ + + id = models.BigAutoField(primary_key=True) + + merchant = models.ForeignKey( + basic_info_models.Merchant, + on_delete=models.PROTECT, + related_name='pre_sales_orders', + verbose_name='所属商户', + ) + customer = models.ForeignKey( + basic_info_models.Customer, + on_delete=models.PROTECT, + related_name='pre_sales_orders', + verbose_name='客户', + ) + created_by = models.ForeignKey( + basic_info_models.Employee, + on_delete=models.PROTECT, + related_name='created_pre_sales_orders', + null=True, + blank=True, + verbose_name='创建者', + ) + warehouse = models.ForeignKey( + basic_info_models.WareHouse, + on_delete=models.PROTECT, + related_name='pre_sales_orders', + verbose_name='仓库', + ) + kind = models.IntegerField( + choices=SalesOrderKindEnum.choices, + default=SalesOrderKindEnum.WHOLESALE, + verbose_name='预销售单类型', + ) + remarks = models.TextField(blank=True, null=True, verbose_name='备注') + + def __str__(self): + return f'预销售单 {self.human_id or self.id}' + + def clean(self): + super().clean() + + errors = {} + + if getattr(self, 'merchant_id', None) and getattr(self, 'customer_id', None): + if self.customer.merchant_id != self.merchant_id: + errors['customer'] = '客户所属商户与预销售单所属商户不一致' + + if getattr(self, 'merchant_id', None) and getattr(self, 'warehouse_id', None): + if self.warehouse.merchant_id != self.merchant_id: + errors['warehouse'] = '仓库所属商户与预销售单所属商户不一致' + + if getattr(self, 'created_by_id', None) and getattr(self, 'merchant_id', None): + if self.created_by.merchant_id != self.merchant_id: + errors['created_by'] = '创建者所属商户与预销售单所属商户不一致' + + if errors: + raise ValidationError(errors) + + @property + def customer_name(self) -> str: + if getattr(self, 'customer_id', None): + return self.customer.name + return '' + + @property + def merchant_name(self) -> str: + if getattr(self, 'merchant_id', None): + return self.merchant.name + return '' + + @property + def created_by_name(self) -> str: + if getattr(self, 'created_by_id', None): + return self.created_by.name + return '' + + @property + def warehouse_name(self) -> str: + if getattr(self, 'warehouse_id', None): + return self.warehouse.name + return '' + + @property + def human_id(self) -> str: + """人类可读编号(虚拟编号,不落库)。 + + 规则:YS + YYYYMMDD{6位id} + """ + if self.id is None or self.created_at is None: + return "" + return f"YS{self.created_at.year}{self.created_at.month:02d}{self.created_at.day:02d}{self.id:06d}" + + class Meta: + verbose_name = '预销售单' + verbose_name_plural = '预销售单' + + +class PreSalesOrderItem(ModelBase): + """预销售单明细""" + + id = models.BigAutoField(primary_key=True) + pre_sales_order = models.ForeignKey( + PreSalesOrder, + on_delete=models.CASCADE, + related_name='items', + verbose_name='预销售单', + ) + + # 暂不与 Product 建立外键,先用弱关联字段承载。 + product_id = models.BigIntegerField(null=True, blank=True, verbose_name='产品ID') + product_name = models.CharField(max_length=100, blank=True, default='', verbose_name='产品名称') + + color = models.CharField(max_length=50, null=True, blank=True, verbose_name='颜色') + quantity = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True, verbose_name='数量') + unit = models.CharField(max_length=50, blank=True, default='', verbose_name='单位') + spec = models.CharField(max_length=100, null=True, blank=True, verbose_name='规格') + quantity_of_rolls = models.TextField( + null=True, + blank=True, + verbose_name='各条数数量', + ) + num_of_rolls = models.PositiveIntegerField(default=1, verbose_name='条数') + order_quantity = models.IntegerField(null=True, blank=True, verbose_name='下单数量') + remarks = models.TextField(blank=True, null=True, verbose_name='备注') + + class Meta: + verbose_name = '预销售单明细' + verbose_name_plural = '预销售单明细' + + class PurchaseReturnStatusEnum(models.IntegerChoices): PENDING = 1, '审批中' APPROVED = 2, '审批通过' diff --git a/business/pre_order_services.py b/business/pre_order_services.py new file mode 100644 index 0000000..fdcd7d6 --- /dev/null +++ b/business/pre_order_services.py @@ -0,0 +1,297 @@ +from __future__ import annotations + +from decimal import Decimal, InvalidOperation +from typing import Any, Dict, List, Optional + +from django.db import transaction +from django.core.exceptions import ValidationError + +from basic_info import models as basic_info_models + +from . import models + + +def _to_decimal(value: Any, *, field_name: str) -> Decimal: + if value is None or value == '': + raise ValueError(f'{field_name} 不能为空') + try: + return Decimal(str(value)) + except (InvalidOperation, ValueError) as exc: + raise ValueError(f'{field_name} 格式不正确') from exc + + +def _to_optional_int(value: Any) -> Optional[int]: + if value is None or value == '': + return None + try: + parsed = int(value) + except (TypeError, ValueError) as exc: + raise ValueError('order_quantity 必须为整数') from exc + if parsed < 0: + raise ValueError('order_quantity 不能为负数') + return parsed + + +def _to_optional_positive_int(value: Any, *, field_name: str) -> Optional[int]: + if value is None or value == '': + return None + try: + parsed = int(value) + except (TypeError, ValueError) as exc: + raise ValueError(f'{field_name} 必须为整数') from exc + if parsed <= 0: + raise ValueError(f'{field_name} 必须大于 0') + return parsed + + +def _normalize_pre_sales_items( + *, + merchant: basic_info_models.Merchant, + items: List[Dict[str, Any]], +) -> List[Dict[str, Any]]: + if not isinstance(items, list) or not items: + raise ValueError('items 需要为非空数组') + + product_ids: List[int] = [] + for item in items: + if not isinstance(item, dict): + raise ValueError('items 每一项需要为对象') + product_id = item.get('product_id') or item.get('product') + if not product_id: + raise ValueError('明细缺少 product_id') + try: + product_ids.append(int(product_id)) + except (TypeError, ValueError) as exc: + raise ValueError('product_id 必须为整数') from exc + + products_by_id = basic_info_models.Product.objects.filter( + merchant=merchant, + id__in=product_ids, + ).in_bulk(field_name='id') + + normalized: List[Dict[str, Any]] = [] + for item in items: + product_id = int(item.get('product_id') or item.get('product')) + product = products_by_id.get(product_id) + if not product: + raise ValueError(f'产品 {product_id} 不存在') + + quantity = _to_decimal(item.get('quantity'), field_name='quantity') + unit = (item.get('unit') or '').strip() + if not unit: + unit = str(product.unit) + if not unit: + raise ValueError('unit 不能为空') + + normalized.append( + { + 'product_id': product_id, + 'product_name': (item.get('product_name') or product.name or '').strip(), + 'color': item.get('color'), + 'quantity': quantity, + 'unit': unit, + 'spec': item.get('spec'), + 'quantity_of_rolls': item.get('quantity_of_rolls'), + 'num_of_rolls': _to_optional_positive_int(item.get('num_of_rolls'), field_name='num_of_rolls'), + 'order_quantity': _to_optional_int(item.get('order_quantity')), + 'remarks': item.get('remarks'), + } + ) + + return normalized + + +def list_pre_sales_orders(*, merchant: basic_info_models.Merchant): + return models.PreSalesOrder.objects.filter(merchant=merchant).select_related( + 'merchant', 'customer', 'warehouse', 'created_by' + ).prefetch_related('items') + + +def get_pre_sales_order(*, merchant: basic_info_models.Merchant, pre_sales_order_id: int) -> models.PreSalesOrder: + try: + return models.PreSalesOrder.objects.select_related( + 'merchant', 'customer', 'warehouse', 'created_by' + ).prefetch_related('items').get(id=pre_sales_order_id, merchant=merchant) + except models.PreSalesOrder.DoesNotExist as exc: + raise ValueError('预销售单不存在') from exc + + +def create_pre_sales_order( + *, + merchant: basic_info_models.Merchant, + customer_id: int | str | None, + warehouse_id: int | str | None, + created_by: basic_info_models.Employee, + kind: int | str | None = None, + items: List[Dict[str, Any]], + remarks: str | None = '', +) -> models.PreSalesOrder: + if not customer_id: + raise ValueError('缺少客户 ID') + if not warehouse_id: + raise ValueError('缺少仓库 ID') + + try: + customer_id_int = int(customer_id) + except (TypeError, ValueError) as exc: + raise ValueError('customer 必须为整数') from exc + + try: + warehouse_id_int = int(warehouse_id) + except (TypeError, ValueError) as exc: + raise ValueError('warehouse 必须为整数') from exc + + kind_int: int | None + if kind is None or kind == '': + kind_int = None + else: + try: + kind_int = int(kind) + except (TypeError, ValueError) as exc: + raise ValueError('kind 必须为整数') from exc + + try: + customer = basic_info_models.Customer.objects.get(id=customer_id_int, merchant=merchant) + except basic_info_models.Customer.DoesNotExist as exc: + raise ValueError(f'客户 {customer_id_int} 不存在') from exc + + try: + warehouse = basic_info_models.WareHouse.objects.get(id=warehouse_id_int, merchant=merchant) + except basic_info_models.WareHouse.DoesNotExist as exc: + raise ValueError(f'仓库 {warehouse_id_int} 不存在') from exc + + if created_by.merchant_id != merchant.id: + raise ValueError('创建者所属商户与预销售单所属商户不一致') + + normalized_items = _normalize_pre_sales_items(merchant=merchant, items=items) + + with transaction.atomic(): + pre_sales_order = models.PreSalesOrder.objects.create( + merchant=merchant, + customer=customer, + warehouse=warehouse, + created_by=created_by, + kind=kind_int or models.SalesOrderKindEnum.WHOLESALE, + remarks=remarks, + ) + + # enforce cross-merchant consistency + try: + pre_sales_order.full_clean() + except ValidationError as exc: + raise ValueError(str(exc)) from exc + + models.PreSalesOrderItem.objects.bulk_create( + [ + models.PreSalesOrderItem( + pre_sales_order=pre_sales_order, + product_id=item['product_id'], + product_name=item.get('product_name') or '', + color=item.get('color'), + quantity=item.get('quantity'), + unit=item.get('unit') or '', + spec=item.get('spec'), + quantity_of_rolls=item.get('quantity_of_rolls'), + num_of_rolls=item.get('num_of_rolls') or 1, + order_quantity=item.get('order_quantity'), + remarks=item.get('remarks'), + ) + for item in normalized_items + ] + ) + + pre_sales_order.refresh_from_db() + return pre_sales_order + + +def update_pre_sales_order( + *, + pre_sales_order: models.PreSalesOrder, + customer_id: int | str | None = None, + warehouse_id: int | str | None = None, + kind: int | str | None = None, + items: List[Dict[str, Any]] | None = None, + remarks: str | None = None, +) -> models.PreSalesOrder: + merchant = pre_sales_order.merchant + + new_customer = pre_sales_order.customer + if customer_id is not None: + if customer_id == '': + raise ValueError('customer 必须为整数') + try: + customer_id_int = int(customer_id) + except (TypeError, ValueError) as exc: + raise ValueError('customer 必须为整数') from exc + try: + new_customer = basic_info_models.Customer.objects.get(id=customer_id_int, merchant=merchant) + except basic_info_models.Customer.DoesNotExist as exc: + raise ValueError(f'客户 {customer_id_int} 不存在') from exc + + new_warehouse = pre_sales_order.warehouse + if warehouse_id is not None: + if warehouse_id == '': + raise ValueError('warehouse 必须为整数') + try: + warehouse_id_int = int(warehouse_id) + except (TypeError, ValueError) as exc: + raise ValueError('warehouse 必须为整数') from exc + try: + new_warehouse = basic_info_models.WareHouse.objects.get(id=warehouse_id_int, merchant=merchant) + except basic_info_models.WareHouse.DoesNotExist as exc: + raise ValueError(f'仓库 {warehouse_id_int} 不存在') from exc + + if kind is None or kind == '': + new_kind = pre_sales_order.kind + else: + try: + new_kind = int(kind) + except (TypeError, ValueError) as exc: + raise ValueError('kind 必须为整数') from exc + new_remarks = remarks if remarks is not None else pre_sales_order.remarks + + if items is None: + raise ValueError('items 需要为非空数组') + + normalized_items = _normalize_pre_sales_items(merchant=merchant, items=items) + + with transaction.atomic(): + pre_sales_order.customer = new_customer + pre_sales_order.warehouse = new_warehouse + pre_sales_order.kind = new_kind + pre_sales_order.remarks = new_remarks + + try: + pre_sales_order.full_clean() + except ValidationError as exc: + raise ValueError(str(exc)) from exc + + pre_sales_order.save(update_fields=['customer', 'warehouse', 'kind', 'remarks', 'updated_at']) + + pre_sales_order.items.all().delete() + models.PreSalesOrderItem.objects.bulk_create( + [ + models.PreSalesOrderItem( + pre_sales_order=pre_sales_order, + product_id=item['product_id'], + product_name=item.get('product_name') or '', + color=item.get('color'), + quantity=item.get('quantity'), + unit=item.get('unit') or '', + spec=item.get('spec'), + quantity_of_rolls=item.get('quantity_of_rolls'), + num_of_rolls=item.get('num_of_rolls') or 1, + order_quantity=item.get('order_quantity'), + remarks=item.get('remarks'), + ) + for item in normalized_items + ] + ) + + pre_sales_order.refresh_from_db() + return pre_sales_order + + +def delete_pre_sales_order(*, pre_sales_order: models.PreSalesOrder) -> None: + with transaction.atomic(): + pre_sales_order.delete() diff --git a/business/tests.py b/business/tests.py deleted file mode 100644 index f363951..0000000 --- a/business/tests.py +++ /dev/null @@ -1,1404 +0,0 @@ -from typing import List, Dict, Any -from decimal import Decimal -from concurrent.futures import ThreadPoolExecutor - -from django.contrib.auth import get_user_model -from django.test import TestCase, TransactionTestCase, override_settings -from django.db import connections, close_old_connections -from django.utils import timezone -from unittest.mock import patch, MagicMock - -from basic_info import models as basic_models -from stock import models as stock_models -from business import models as business_models, services, tasks - - -def create_basic_fixtures(): - merchant = basic_models.Merchant.objects.create( - name='测试商户', - type=basic_models.MerchantTypeEnum.FACTORY, - ) - basic_models.MerchantSetting.objects.create( - merchant=merchant, - key=basic_models.MerchantSettingKeyEnum.AUTO_CREATE_STOCK_CHANGE_TASKS, - type=basic_models.MerchantSettingTypeEnum.BOOL, - val_bool=True, - ) - supplier = basic_models.Supplier.objects.create( - merchant=merchant, - name='测试供应商', - ) - warehouse_strict = basic_models.WareHouse.objects.create( - merchant=merchant, - name='严进仓', - mode=basic_models.WareHouseModeEnum.RESTRICT_IN, - ) - warehouse_relaxed = basic_models.WareHouse.objects.create( - merchant=merchant, - name='宽进仓', - mode=basic_models.WareHouseModeEnum.UNRESTRICTED, - ) - category = basic_models.ProductCategory.objects.create( - merchant=merchant, - name='面料', - product_prefix='FAB', - ) - product = basic_models.Product.objects.create( - merchant=merchant, - category=category, - name='测试面料', - human_id='FAB-001', - unit=basic_models.ProductUnitEnum.METER, - ) - operator = basic_models.Employee.objects.create( - merchant=merchant, - name='经办人', - status=basic_models.EmployeeStatusEnum.ACTIVE, - ) - return merchant, supplier, warehouse_strict, warehouse_relaxed, product, operator - - -def create_sales_fixtures(): - merchant = basic_models.Merchant.objects.create( - name='销售商户', - type=basic_models.MerchantTypeEnum.FACTORY, - ) - basic_models.MerchantSetting.objects.create( - merchant=merchant, - key=basic_models.MerchantSettingKeyEnum.AUTO_CREATE_STOCK_CHANGE_TASKS, - type=basic_models.MerchantSettingTypeEnum.BOOL, - val_bool=True, - ) - customer = basic_models.Customer.objects.create( - merchant=merchant, - name='客户A', - mobile='13800000000', - created_by=None, - ) - warehouse_strict = basic_models.WareHouse.objects.create( - merchant=merchant, - name='销售严进仓', - mode=basic_models.WareHouseModeEnum.RESTRICT_IN, - ) - warehouse_relaxed = basic_models.WareHouse.objects.create( - merchant=merchant, - name='销售宽进仓', - mode=basic_models.WareHouseModeEnum.UNRESTRICTED, - ) - warehouse_strict_out = basic_models.WareHouse.objects.create( - merchant=merchant, - name='销售严出仓', - mode=basic_models.WareHouseModeEnum.RESTRICT_IN_OUT, - ) - category = basic_models.ProductCategory.objects.create( - merchant=merchant, - name='销售品类', - product_prefix='SAL', - ) - product = basic_models.Product.objects.create( - merchant=merchant, - category=category, - name='销售面料', - human_id='SAL-001', - unit=basic_models.ProductUnitEnum.METER, - ) - operator = basic_models.Employee.objects.create( - merchant=merchant, - name='销售员', - status=basic_models.EmployeeStatusEnum.ACTIVE, - ) - return merchant, customer, warehouse_strict, warehouse_relaxed, warehouse_strict_out, product, operator - - -class PurchaseOrderServiceTestCase(TestCase): - def setUp(self): - ( - self.merchant, - self.supplier, - self.warehouse_strict, - self.warehouse_relaxed, - self.product, - self.operator, - ) = create_basic_fixtures() - User = get_user_model() - self.user = User.objects.create_user(username='creator', password='pass123') - self.strict_items: List[Dict[str, Any]] = [ - {'product_id': self.product.id, 'numbers': [10, 5], 'price': '12.5', 'unit': '米'} - ] - self.relaxed_items: List[Dict[str, Any]] = [ - {'product_id': self.product.id, 'quantity': 120, 'num_of_rolls': 3, 'price': '10.0', 'unit': '米'} - ] - - def test_create_purchase_order_does_not_trigger_task_immediately(self): - with patch('business.services.create_purchase_order_stock_entries.delay') as mock_delay: - purchase_order = services.create_purchase_order( - merchant=self.merchant, - supplier=self.supplier, - order_date=timezone.now().date(), - warehouse=self.warehouse_strict, - operator=self.operator, - items=self.strict_items, - remarks='自动化测试', - created_by=self.user, - ) - - self.assertIsInstance(purchase_order, business_models.PurchaseOrder) - self.assertEqual(purchase_order.status, business_models.PurchaseOrderStatusEnum.PENDING) - self.assertEqual(purchase_order.items.count(), 1) - item = purchase_order.items.first() - self.assertEqual(item.quantity_of_rolls, '10,5') - self.assertEqual(item.split_quantity_of_rolls(), [10, 5]) - mock_delay.assert_not_called() - - def test_purchase_order_human_id_generation(self): - purchase_order = services.create_purchase_order( - merchant=self.merchant, - supplier=self.supplier, - order_date=timezone.now().date(), - warehouse=self.warehouse_strict, - operator=self.operator, - items=self.strict_items, - created_by=self.user, - ) - self.assertIsNotNone(purchase_order.human_id) - self.assertEqual( - purchase_order.human_id, - f"CG{purchase_order.created_at.strftime('%Y%m%d')}{purchase_order.id:06d}", - ) - - def test_review_purchase_order_approval_triggers_task(self): - purchase_order = services.create_purchase_order( - merchant=self.merchant, - supplier=self.supplier, - order_date=timezone.now().date(), - warehouse=self.warehouse_strict, - operator=self.operator, - items=self.strict_items, - created_by=self.user, - ) - - with patch('business.services.create_purchase_order_stock_entries.delay') as mock_delay: - reviewed = services.review_purchase_order( - purchase_order=purchase_order, - target_status=business_models.PurchaseOrderStatusEnum.APPROVED, - reviewed_by=self.user, - ) - - self.assertEqual(reviewed.status, business_models.PurchaseOrderStatusEnum.APPROVED) - mock_delay.assert_called_once_with( - purchase_order_id=purchase_order.id, - warehouse_id=self.warehouse_strict.id, - items=[{'product_id': self.product.id, 'quantities': ['10', '5']}], - created_by_id=self.user.id, - ) - balance = business_models.SupplierBalance.objects.get( - merchant=self.merchant, - supplier=self.supplier, - ) - self.assertEqual(balance.balance, purchase_order.get_total_amount()) - record = business_models.BalanceChangeRecord.objects.get( - merchant=self.merchant, - source_type=business_models.BalanceChangeSourceEnum.PURCHASE_ORDER, - source_id=purchase_order.id, - ) - self.assertEqual(record.target_type, business_models.BalanceChangeTargetEnum.SUPPLIER) - self.assertEqual(record.delta, purchase_order.get_total_amount()) - self.assertEqual(record.balance_after, balance.balance) - self.assertEqual(record.direction, business_models.BalanceChangeDirectionEnum.INCREASE) - - def test_create_purchase_order_without_items_raises(self): - with self.assertRaises(ValueError): - services.create_purchase_order( - merchant=self.merchant, - supplier=self.supplier, - order_date=timezone.now().date(), - warehouse=self.warehouse_strict, - operator=self.operator, - items=[], - ) - - def test_review_purchase_order_cancel_without_stock(self): - purchase_order = services.create_purchase_order( - merchant=self.merchant, - supplier=self.supplier, - order_date=timezone.now().date(), - warehouse=self.warehouse_relaxed, - operator=self.operator, - items=self.relaxed_items, - created_by=self.user, - ) - item = purchase_order.items.first() - self.assertIsNone(item.quantity_of_rolls) - self.assertEqual(item.split_quantity_of_rolls(), []) - - cancelled = services.review_purchase_order( - purchase_order=purchase_order, - target_status=business_models.PurchaseOrderStatusEnum.CANCELLED, - reviewed_by=self.user, - ) - self.assertEqual(cancelled.status, business_models.PurchaseOrderStatusEnum.CANCELLED) - self.assertFalse( - business_models.BalanceChangeRecord.objects.filter( - merchant=self.merchant, - source_type=business_models.BalanceChangeSourceEnum.PURCHASE_ORDER, - source_id=purchase_order.id, - ).exists() - ) - - def test_review_purchase_order_cancel_blocked_after_stock_created(self): - purchase_order = services.create_purchase_order( - merchant=self.merchant, - supplier=self.supplier, - order_date=timezone.now().date(), - warehouse=self.warehouse_relaxed, - operator=self.operator, - items=self.relaxed_items, - created_by=self.user, - ) - stock_models.StockChangeRecord.objects.create( - merchant=self.merchant, - type=stock_models.StockChangeTypeEnum.ADD, - warehouse=self.warehouse_relaxed, - source_type=stock_models.StockChangeSourceEnum.PURCHASE, - source_id=purchase_order.id, - ) - - with self.assertRaises(ValueError): - services.review_purchase_order( - purchase_order=purchase_order, - target_status=business_models.PurchaseOrderStatusEnum.CANCELLED, - reviewed_by=self.user, - ) - - def test_review_purchase_order_relaxed_mode_approval_payload(self): - purchase_order = services.create_purchase_order( - merchant=self.merchant, - supplier=self.supplier, - order_date=timezone.now().date(), - warehouse=self.warehouse_relaxed, - operator=self.operator, - items=self.relaxed_items, - created_by=self.user, - ) - - with patch('business.services.create_purchase_order_stock_entries.delay') as mock_delay: - services.review_purchase_order( - purchase_order=purchase_order, - target_status=business_models.PurchaseOrderStatusEnum.APPROVED, - reviewed_by=self.user, - ) - - mock_delay.assert_called_once_with( - purchase_order_id=purchase_order.id, - warehouse_id=self.warehouse_relaxed.id, - items=[{'product_id': self.product.id, 'value': '120.00', 'num_of_rolls': 3}], - created_by_id=self.user.id, - ) - - def test_update_purchase_order_success(self): - purchase_order = services.create_purchase_order( - merchant=self.merchant, - supplier=self.supplier, - order_date=timezone.now().date(), - warehouse=self.warehouse_relaxed, - operator=self.operator, - items=self.relaxed_items, - created_by=self.user, - ) - updated = services.update_purchase_order( - purchase_order=purchase_order, - warehouse=self.warehouse_strict, - items=self.strict_items, - remarks='updated', - ) - self.assertEqual(updated.remarks, 'updated') - self.assertEqual(updated.items.count(), 1) - self.assertEqual(updated.items.first().split_quantity_of_rolls(), [10, 5]) - - def test_update_purchase_order_rejects_non_pending(self): - purchase_order = services.create_purchase_order( - merchant=self.merchant, - supplier=self.supplier, - order_date=timezone.now().date(), - warehouse=self.warehouse_relaxed, - operator=self.operator, - items=self.relaxed_items, - created_by=self.user, - ) - purchase_order.status = business_models.PurchaseOrderStatusEnum.APPROVED - purchase_order.save(update_fields=['status']) - with self.assertRaises(ValueError): - services.update_purchase_order( - purchase_order=purchase_order, - items=self.relaxed_items, - ) - - def test_purchase_order_relaxed_mode_even_split(self): - purchase_order = services.create_purchase_order( - merchant=self.merchant, - supplier=self.supplier, - order_date=timezone.now().date(), - warehouse=self.warehouse_relaxed, - operator=self.operator, - items=[{'product_id': self.product.id, 'quantity': 99, 'num_of_rolls': 2, 'price': '10.0', 'unit': '米'}], - created_by=self.user, - ) - stock_flow_items = services._build_stock_flow_items_from_order(purchase_order) - payload = services.create_purchase_order_stock_entries_sync( - purchase_order_id=purchase_order.id, - warehouse_id=self.warehouse_relaxed.id, - items=stock_flow_items, - created_by_id=self.user.id, - ) - record = stock_models.StockChangeRecord.objects.get(id=payload['stock_change_record_id']) - quantities = sorted([detail.quantity for detail in record.details.order_by('id')], reverse=True) - self.assertEqual(quantities, [Decimal('49.50'), Decimal('49.50')]) - - -class SalesOrderServiceTestCase(TestCase): - def setUp(self): - ( - self.merchant, - self.customer, - self.warehouse_strict, - self.warehouse_relaxed, - self.warehouse_strict_out, - self.product, - self.operator, - ) = create_sales_fixtures() - User = get_user_model() - self.user = User.objects.create_user(username='sales-creator', password='pass123') - self.strict_items = [ - {'product_id': self.product.id, 'numbers': [6, 4], 'price': '30.5', 'unit': '米'} - ] - self.relaxed_items = [ - {'product_id': self.product.id, 'quantity': 80, 'num_of_rolls': 2, 'price': '28.0', 'unit': '米'} - ] - self.strict_out_items = [ - { - 'product_id': self.product.id, - 'consume_detail_ids': [1, 2], - 'quantity': 40, - 'price': '32.0', - 'unit': '米', - } - ] - - def test_create_sales_order_success(self): - with patch('business.services.create_sales_order_stock_entries.delay') as mock_delay: - sales_order = services.create_sales_order( - merchant=self.merchant, - customer=self.customer, - order_date=timezone.now().date(), - warehouse=self.warehouse_strict, - operator=self.operator, - items=self.strict_items, - remarks='销售测试', - created_by=self.user, - ) - - self.assertIsInstance(sales_order, business_models.SalesOrder) - self.assertEqual(sales_order.status, business_models.SalesOrderStatusEnum.PENDING) - self.assertEqual(sales_order.items.count(), 1) - item = sales_order.items.first() - self.assertEqual(item.quantity_of_rolls, '6,4') - self.assertEqual(item.split_quantity_of_rolls(), [6, 4]) - mock_delay.assert_not_called() - - def test_sales_order_human_id_generation(self): - sales_order = services.create_sales_order( - merchant=self.merchant, - customer=self.customer, - order_date=timezone.now().date(), - warehouse=self.warehouse_strict, - operator=self.operator, - items=self.strict_items, - created_by=self.user, - ) - - self.assertIsNotNone(sales_order.human_id) - self.assertTrue(len(sales_order.human_id) >= 14) - self.assertEqual( - sales_order.human_id, - f"XS{sales_order.created_at.strftime('%Y%m%d')}{sales_order.id:06d}", - ) - - def test_review_sales_order_triggers_task(self): - sales_order = services.create_sales_order( - merchant=self.merchant, - customer=self.customer, - order_date=timezone.now().date(), - warehouse=self.warehouse_strict, - operator=self.operator, - items=self.strict_items, - created_by=self.user, - ) - with patch('business.services.create_sales_order_stock_entries.delay') as mock_delay: - reviewed = services.review_sales_order( - sales_order=sales_order, - target_status=business_models.SalesOrderStatusEnum.APPROVED, - reviewed_by=self.user, - ) - self.assertEqual(reviewed.status, business_models.SalesOrderStatusEnum.APPROVED) - mock_delay.assert_called_once_with( - sales_order_id=sales_order.id, - warehouse_id=self.warehouse_strict.id, - items=[{'product_id': self.product.id, 'quantities': ['6', '4']}], - created_by_id=self.user.id, - ) - balance = business_models.CustomerBalance.objects.get( - merchant=self.merchant, - customer=self.customer, - ) - self.assertEqual(balance.balance, sales_order.get_total_amount()) - record = business_models.BalanceChangeRecord.objects.get( - merchant=self.merchant, - source_type=business_models.BalanceChangeSourceEnum.SALES_ORDER, - source_id=sales_order.id, - ) - self.assertEqual(record.target_type, business_models.BalanceChangeTargetEnum.CUSTOMER) - self.assertEqual(record.delta, sales_order.get_total_amount()) - self.assertEqual(record.balance_after, balance.balance) - self.assertEqual(record.direction, business_models.BalanceChangeDirectionEnum.INCREASE) - - def test_sales_order_cancel_blocked_after_stock_created(self): - sales_order = services.create_sales_order( - merchant=self.merchant, - customer=self.customer, - order_date=timezone.now().date(), - warehouse=self.warehouse_relaxed, - operator=self.operator, - items=self.relaxed_items, - created_by=self.user, - ) - stock_models.StockChangeRecord.objects.create( - merchant=self.merchant, - type=stock_models.StockChangeTypeEnum.REMOVE, - warehouse=self.warehouse_relaxed, - source_type=stock_models.StockChangeSourceEnum.SALES, - source_id=sales_order.id, - ) - with self.assertRaises(ValueError): - services.review_sales_order( - sales_order=sales_order, - target_status=business_models.SalesOrderStatusEnum.CANCELLED, - reviewed_by=self.user, - ) - self.assertFalse( - business_models.BalanceChangeRecord.objects.filter( - merchant=self.merchant, - source_type=business_models.BalanceChangeSourceEnum.SALES_ORDER, - source_id=sales_order.id, - ).exists() - ) - - def test_sales_order_requires_consume_ids_for_strict_out(self): - with self.assertRaises(ValueError): - services.create_sales_order( - merchant=self.merchant, - customer=self.customer, - order_date=timezone.now().date(), - warehouse=self.warehouse_strict_out, - operator=self.operator, - items=[{'product_id': self.product.id, 'quantity': 10, 'price': '20', 'unit': '米'}], - created_by=self.user, - ) - - def test_sales_order_stores_consume_ids(self): - sales_order = services.create_sales_order( - merchant=self.merchant, - customer=self.customer, - order_date=timezone.now().date(), - warehouse=self.warehouse_strict_out, - operator=self.operator, - items=self.strict_out_items, - created_by=self.user, - ) - item = sales_order.items.first() - self.assertEqual(item.consume_detail_ids, '1,2') - - def test_sales_order_outgoing_missing_consume_ids_raises(self): - payload = [{ - 'product_id': self.product.id, - 'quantity': 30, - 'price': '25', - 'unit': '米', - }] - with self.assertRaises(ValueError): - services.create_sales_order( - merchant=self.merchant, - customer=self.customer, - order_date=timezone.now().date(), - warehouse=self.warehouse_strict_out, - operator=self.operator, - items=payload, - created_by=self.user, - ) - - def test_update_sales_order_success(self): - sales_order = services.create_sales_order( - merchant=self.merchant, - customer=self.customer, - order_date=timezone.now().date(), - warehouse=self.warehouse_strict, - operator=self.operator, - items=self.strict_items, - created_by=self.user, - ) - updated = services.update_sales_order( - sales_order=sales_order, - warehouse=self.warehouse_relaxed, - items=self.relaxed_items, - remarks='updated', - ) - self.assertEqual(updated.remarks, 'updated') - self.assertEqual(updated.items.count(), 1) - item = updated.items.first() - self.assertEqual(item.split_quantity_of_rolls(), []) - - def test_update_sales_order_rejects_non_pending(self): - sales_order = services.create_sales_order( - merchant=self.merchant, - customer=self.customer, - order_date=timezone.now().date(), - warehouse=self.warehouse_strict, - operator=self.operator, - items=self.strict_items, - created_by=self.user, - ) - sales_order.status = business_models.SalesOrderStatusEnum.APPROVED - sales_order.save(update_fields=['status']) - with self.assertRaises(ValueError): - services.update_sales_order( - sales_order=sales_order, - items=self.strict_items, - ) - - -class PurchaseReturnServiceTestCase(TestCase): - def setUp(self): - ( - self.merchant, - self.supplier, - self.warehouse_strict, - self.warehouse_relaxed, - self.product, - self.operator, - ) = create_basic_fixtures() - self.warehouse_strict_out = basic_models.WareHouse.objects.create( - merchant=self.merchant, - name='采退严出仓', - mode=basic_models.WareHouseModeEnum.RESTRICT_IN_OUT, - ) - User = get_user_model() - self.user = User.objects.create_user(username='purchase-return', password='pass123') - self.strict_items = [ - {'product_id': self.product.id, 'numbers': [4, 2], 'price': '11.5', 'unit': '米'} - ] - self.strict_out_items = [ - { - 'product_id': self.product.id, - 'consume_detail_ids': [10, 11], - 'quantity': 30, - 'price': '12.0', - 'unit': '米', - } - ] - - def test_create_purchase_return_order_success(self): - order = services.create_purchase_return_order( - merchant=self.merchant, - supplier=self.supplier, - return_date=timezone.now().date(), - warehouse=self.warehouse_strict, - operator=self.operator, - items=self.strict_items, - created_by=self.user, - ) - self.assertEqual(order.status, business_models.PurchaseReturnStatusEnum.PENDING) - self.assertEqual(order.items.count(), 1) - item = order.items.first() - self.assertEqual(item.quantity_of_rolls, '4,2') - self.assertEqual(item.split_quantity_of_rolls(), [4, 2]) - - def test_purchase_return_order_human_id_generation(self): - order = services.create_purchase_return_order( - merchant=self.merchant, - supplier=self.supplier, - return_date=timezone.now().date(), - warehouse=self.warehouse_strict, - operator=self.operator, - items=self.strict_items, - created_by=self.user, - ) - self.assertIsNotNone(order.human_id) - self.assertEqual( - order.human_id, - f"CT{order.created_at.strftime('%Y%m%d')}{order.id:06d}", - ) - - def test_review_purchase_return_order_triggers_task_and_balance(self): - order = services.create_purchase_return_order( - merchant=self.merchant, - supplier=self.supplier, - return_date=timezone.now().date(), - warehouse=self.warehouse_relaxed, - operator=self.operator, - items=[{'product_id': self.product.id, 'quantity': 40, 'num_of_rolls': 2, 'price': '10.0'}], - created_by=self.user, - ) - with patch('business.services.create_purchase_return_order_stock_entries.delay') as mock_delay: - reviewed = services.review_purchase_return_order( - purchase_return_order=order, - target_status=business_models.PurchaseReturnStatusEnum.APPROVED, - reviewed_by=self.user, - ) - self.assertEqual(reviewed.status, business_models.PurchaseReturnStatusEnum.APPROVED) - mock_delay.assert_called_once() - balance = business_models.SupplierBalance.objects.get( - merchant=self.merchant, - supplier=self.supplier, - ) - self.assertEqual(balance.balance, Decimal('-400.00')) - record = business_models.BalanceChangeRecord.objects.get( - merchant=self.merchant, - source_type=business_models.BalanceChangeSourceEnum.PURCHASE_RETURN_ORDER, - source_id=order.id, - ) - self.assertEqual(record.direction, business_models.BalanceChangeDirectionEnum.DECREASE) - - def test_purchase_return_cancel_blocked_after_stock_created(self): - order = services.create_purchase_return_order( - merchant=self.merchant, - supplier=self.supplier, - return_date=timezone.now().date(), - warehouse=self.warehouse_relaxed, - operator=self.operator, - items=[{'product_id': self.product.id, 'quantity': 20, 'num_of_rolls': 2, 'price': '9.5'}], - ) - stock_models.StockChangeRecord.objects.create( - merchant=self.merchant, - type=stock_models.StockChangeTypeEnum.REMOVE, - warehouse=self.warehouse_relaxed, - source_type=stock_models.StockChangeSourceEnum.PURCHASE_RETURN, - source_id=order.id, - ) - with self.assertRaises(ValueError): - services.review_purchase_return_order( - purchase_return_order=order, - target_status=business_models.PurchaseReturnStatusEnum.CANCELLED, - ) - - def test_purchase_return_strict_out_requires_consume_ids(self): - payload = [ - { - 'product_id': self.product.id, - 'quantity': 10, - 'price': '8.5', - 'unit': '米', - } - ] - with self.assertRaises(ValueError): - services.create_purchase_return_order( - merchant=self.merchant, - supplier=self.supplier, - return_date=timezone.now().date(), - warehouse=self.warehouse_strict_out, - operator=self.operator, - items=payload, - ) - - def test_update_purchase_return_order_success(self): - order = services.create_purchase_return_order( - merchant=self.merchant, - supplier=self.supplier, - return_date=timezone.now().date(), - warehouse=self.warehouse_strict, - operator=self.operator, - items=self.strict_items, - created_by=self.user, - ) - updated = services.update_purchase_return_order( - purchase_return_order=order, - items=self.strict_items, - remarks='updated', - ) - self.assertEqual(updated.remarks, 'updated') - self.assertEqual(updated.items.count(), 1) - self.assertEqual(updated.items.first().split_quantity_of_rolls(), [4, 2]) - - def test_update_purchase_return_order_rejects_non_pending(self): - order = services.create_purchase_return_order( - merchant=self.merchant, - supplier=self.supplier, - return_date=timezone.now().date(), - warehouse=self.warehouse_strict, - operator=self.operator, - items=self.strict_items, - created_by=self.user, - ) - order.status = business_models.PurchaseReturnStatusEnum.APPROVED - order.save(update_fields=['status']) - with self.assertRaises(ValueError): - services.update_purchase_return_order( - purchase_return_order=order, - items=self.strict_items, - ) - - -class SalesReturnServiceTestCase(TestCase): - def setUp(self): - ( - self.merchant, - self.customer, - self.warehouse_strict, - self.warehouse_relaxed, - self.warehouse_strict_out, - self.product, - self.operator, - ) = create_sales_fixtures() - User = get_user_model() - self.user = User.objects.create_user(username='sales-return', password='pass123') - self.strict_items = [ - {'product_id': self.product.id, 'numbers': [6, 2], 'price': '20.0', 'unit': '米'} - ] - - def test_create_sales_return_order_success(self): - order = services.create_sales_return_order( - merchant=self.merchant, - customer=self.customer, - return_date=timezone.now().date(), - warehouse=self.warehouse_relaxed, - operator=self.operator, - items=[{'product_id': self.product.id, 'quantity': 50, 'num_of_rolls': 2, 'price': '19.5'}], - ) - self.assertEqual(order.status, business_models.SalesReturnStatusEnum.PENDING) - item = order.items.first() - self.assertEqual(item.split_quantity_of_rolls(), []) - - def test_sales_return_order_human_id_generation(self): - order = services.create_sales_return_order( - merchant=self.merchant, - customer=self.customer, - return_date=timezone.now().date(), - warehouse=self.warehouse_relaxed, - operator=self.operator, - items=[{'product_id': self.product.id, 'quantity': 50, 'num_of_rolls': 2, 'price': '19.5'}], - created_by=self.user, - ) - self.assertIsNotNone(order.human_id) - self.assertEqual( - order.human_id, - f"XT{order.created_at.strftime('%Y%m%d')}{order.id:06d}", - ) - - def test_review_sales_return_order_updates_balance(self): - order = services.create_sales_return_order( - merchant=self.merchant, - customer=self.customer, - return_date=timezone.now().date(), - warehouse=self.warehouse_strict, - operator=self.operator, - items=self.strict_items, - created_by=self.user, - ) - self.assertEqual(order.items.first().split_quantity_of_rolls(), [6, 2]) - with patch('business.services.create_sales_return_order_stock_entries.delay') as mock_delay: - reviewed = services.review_sales_return_order( - sales_return_order=order, - target_status=business_models.SalesReturnStatusEnum.APPROVED, - reviewed_by=self.user, - ) - self.assertEqual(reviewed.status, business_models.SalesReturnStatusEnum.APPROVED) - mock_delay.assert_called_once() - balance = business_models.CustomerBalance.objects.get( - merchant=self.merchant, - customer=self.customer, - ) - self.assertLess(balance.balance, Decimal('0')) - record = business_models.BalanceChangeRecord.objects.get( - merchant=self.merchant, - source_type=business_models.BalanceChangeSourceEnum.SALES_RETURN_ORDER, - source_id=order.id, - ) - self.assertEqual(record.direction, business_models.BalanceChangeDirectionEnum.DECREASE) - - def test_sales_return_cancel_blocked_after_stock_created(self): - order = services.create_sales_return_order( - merchant=self.merchant, - customer=self.customer, - return_date=timezone.now().date(), - warehouse=self.warehouse_relaxed, - operator=self.operator, - items=[{'product_id': self.product.id, 'quantity': 15, 'num_of_rolls': 1, 'price': '18.0'}], - ) - stock_models.StockChangeRecord.objects.create( - merchant=self.merchant, - type=stock_models.StockChangeTypeEnum.ADD, - warehouse=self.warehouse_relaxed, - source_type=stock_models.StockChangeSourceEnum.SALES_RETURN, - source_id=order.id, - ) - with self.assertRaises(ValueError): - services.review_sales_return_order( - sales_return_order=order, - target_status=business_models.SalesReturnStatusEnum.CANCELLED, - ) - - def test_sales_return_relaxed_mode_stock_payload_preserves_rolls(self): - order = services.create_sales_return_order( - merchant=self.merchant, - customer=self.customer, - return_date=timezone.now().date(), - warehouse=self.warehouse_relaxed, - operator=self.operator, - items=[{'product_id': self.product.id, 'quantity': 100, 'num_of_rolls': 1, 'price': '22.0'}], - ) - stock_flow_items = services._build_stock_flow_items_from_order(order) - self.assertEqual(len(stock_flow_items), 1) - self.assertEqual(stock_flow_items[0]['num_of_rolls'], 1) - - def test_sales_return_relaxed_mode_even_split(self): - order = services.create_sales_return_order( - merchant=self.merchant, - customer=self.customer, - return_date=timezone.now().date(), - warehouse=self.warehouse_relaxed, - operator=self.operator, - items=[{'product_id': self.product.id, 'quantity': 100, 'num_of_rolls': 3, 'price': '22.0'}], - created_by=self.user, - ) - stock_flow_items = services._build_stock_flow_items_from_order(order) - payload = services.create_sales_return_order_stock_entries_sync( - sales_return_order_id=order.id, - warehouse_id=self.warehouse_relaxed.id, - items=stock_flow_items, - created_by_id=self.user.id, - ) - record = stock_models.StockChangeRecord.objects.get(id=payload['stock_change_record_id']) - quantities = sorted([detail.quantity for detail in record.details.order_by('id')], reverse=True) - self.assertEqual(quantities, [Decimal('33.34'), Decimal('33.33'), Decimal('33.33')]) - - def test_update_sales_return_order_success(self): - order = services.create_sales_return_order( - merchant=self.merchant, - customer=self.customer, - return_date=timezone.now().date(), - warehouse=self.warehouse_strict, - operator=self.operator, - items=self.strict_items, - created_by=self.user, - ) - updated = services.update_sales_return_order( - sales_return_order=order, - items=self.strict_items, - remarks='updated', - ) - self.assertEqual(updated.remarks, 'updated') - self.assertEqual(updated.items.count(), 1) - self.assertEqual(updated.items.first().split_quantity_of_rolls(), [6, 2]) - - def test_update_sales_return_order_rejects_non_pending(self): - order = services.create_sales_return_order( - merchant=self.merchant, - customer=self.customer, - return_date=timezone.now().date(), - warehouse=self.warehouse_strict, - operator=self.operator, - items=self.strict_items, - created_by=self.user, - ) - order.status = business_models.SalesReturnStatusEnum.APPROVED - order.save(update_fields=['status']) - with self.assertRaises(ValueError): - services.update_sales_return_order( - sales_return_order=order, - items=self.strict_items, - ) - -class PaymentReceiptServiceTestCase(TestCase): - def setUp(self): - ( - self.merchant, - self.supplier, - self.warehouse_strict, - self.warehouse_relaxed, - self.product, - self.operator, - ) = create_basic_fixtures() - self.customer = basic_models.Customer.objects.create( - merchant=self.merchant, - name='客户B', - mobile='13888888888', - created_by=None, - ) - self.bank_account = basic_models.BankAccount.objects.create( - merchant=self.merchant, - name='测试账户', - auto_number='BANK-SVC', - ) - - def test_create_payment_order_and_approve(self): - order = services.create_payment_order( - merchant=self.merchant, - supplier=self.supplier, - payment_date=timezone.now().date(), - amount='120.50', - discount_amount='20.50', - operator=self.operator, - remarks='付款测试', - bank_account=self.bank_account, - markup='服务层附言', - ) - self.assertEqual(order.status, business_models.PaymentOrderStatusEnum.PENDING) - self.assertEqual(order.bank_account, self.bank_account) - self.assertEqual(order.markup, '服务层附言') - self.assertEqual(order.discount_amount, Decimal('20.50')) - self.assertEqual(order.settlement_amount, Decimal('141.00')) - reviewed = services.review_payment_order( - payment_order=order, - target_status=business_models.PaymentOrderStatusEnum.APPROVED, - reviewed_by=self.operator, - ) - self.assertEqual(reviewed.status, business_models.PaymentOrderStatusEnum.APPROVED) - balance = business_models.SupplierBalance.objects.get( - merchant=self.merchant, - supplier=self.supplier, - ) - self.assertEqual(balance.balance, Decimal('-141.00')) - record = business_models.BalanceChangeRecord.objects.get( - merchant=self.merchant, - source_type=business_models.BalanceChangeSourceEnum.PAYMENT_ORDER, - source_id=order.id, - ) - self.assertEqual(record.direction, business_models.BalanceChangeDirectionEnum.DECREASE) - self.assertEqual(record.delta, Decimal('-141.00')) - self.assertEqual(record.balance_after, balance.balance) - with self.assertRaises(ValueError): - services.review_payment_order( - payment_order=order, - target_status=business_models.PaymentOrderStatusEnum.CANCELLED, - reviewed_by=self.operator, - ) - - def test_create_receipt_order_and_cancel(self): - order = services.create_receipt_order( - merchant=self.merchant, - customer=self.customer, - receipt_date=timezone.now().date(), - amount='80.00', - discount_amount='5', - operator=self.operator, - remarks='收款测试', - bank_account=self.bank_account, - markup='收款附言', - ) - self.assertEqual(order.bank_account, self.bank_account) - self.assertEqual(order.discount_amount, Decimal('5')) - self.assertEqual(order.settlement_amount, Decimal('85')) - cancelled = services.review_receipt_order( - receipt_order=order, - target_status=business_models.ReceiptOrderStatusEnum.CANCELLED, - reviewed_by=self.operator, - ) - self.assertEqual(cancelled.status, business_models.ReceiptOrderStatusEnum.CANCELLED) - self.assertFalse( - business_models.BalanceChangeRecord.objects.filter( - merchant=self.merchant, - source_type=business_models.BalanceChangeSourceEnum.RECEIPT_ORDER, - source_id=order.id, - ).exists() - ) - - def test_payment_amount_can_be_negative(self): - order = services.create_payment_order( - merchant=self.merchant, - supplier=self.supplier, - payment_date=timezone.now().date(), - amount='-25.50', - operator=self.operator, - ) - self.assertEqual(order.amount, Decimal('-25.50')) - - def test_payment_discount_can_exceed_amount(self): - order = services.create_payment_order( - merchant=self.merchant, - supplier=self.supplier, - payment_date=timezone.now().date(), - amount='50', - discount_amount='60', - operator=self.operator, - ) - self.assertEqual(order.settlement_amount, Decimal('110')) - - def test_receipt_discount_can_exceed_amount(self): - order = services.create_receipt_order( - merchant=self.merchant, - customer=self.customer, - receipt_date=timezone.now().date(), - amount='50', - discount_amount='60', - operator=self.operator, - ) - self.assertEqual(order.settlement_amount, Decimal('110')) - - def test_receipt_amount_can_be_negative(self): - order = services.create_receipt_order( - merchant=self.merchant, - customer=self.customer, - receipt_date=timezone.now().date(), - amount='-40', - operator=self.operator, - ) - self.assertEqual(order.amount, Decimal('-40')) - - def test_payment_order_rejects_foreign_bank_account(self): - other_merchant = basic_models.Merchant.objects.create( - name='无关商户', - type=basic_models.MerchantTypeEnum.FACTORY, - ) - foreign_bank = basic_models.BankAccount.objects.create( - merchant=other_merchant, - name='其他账户', - auto_number='BANK-FOREIGN', - ) - with self.assertRaises(ValueError): - services.create_payment_order( - merchant=self.merchant, - supplier=self.supplier, - payment_date=timezone.now().date(), - amount='10', - operator=self.operator, - bank_account=foreign_bank, - ) - - def test_receipt_order_rejects_foreign_bank_account(self): - other_merchant = basic_models.Merchant.objects.create( - name='第二商户', - type=basic_models.MerchantTypeEnum.FACTORY, - ) - foreign_bank = basic_models.BankAccount.objects.create( - merchant=other_merchant, - name='第二账户', - auto_number='BANK-SECOND', - ) - with self.assertRaises(ValueError): - services.create_receipt_order( - merchant=self.merchant, - customer=self.customer, - receipt_date=timezone.now().date(), - amount='10', - operator=self.operator, - bank_account=foreign_bank, - ) - - def test_receipt_approval_is_idempotent(self): - order = services.create_receipt_order( - merchant=self.merchant, - customer=self.customer, - receipt_date=timezone.now().date(), - amount='10', - operator=self.operator, - ) - services.review_receipt_order( - receipt_order=order, - target_status=business_models.ReceiptOrderStatusEnum.APPROVED, - reviewed_by=self.operator, - ) - reviewed_again = services.review_receipt_order( - receipt_order=order, - target_status=business_models.ReceiptOrderStatusEnum.APPROVED, - reviewed_by=self.operator, - ) - self.assertEqual(reviewed_again.status, business_models.ReceiptOrderStatusEnum.APPROVED) - balance = business_models.CustomerBalance.objects.get( - merchant=self.merchant, - customer=self.customer, - ) - self.assertEqual(balance.balance, Decimal('-10')) - records = business_models.BalanceChangeRecord.objects.filter( - merchant=self.merchant, - source_type=business_models.BalanceChangeSourceEnum.RECEIPT_ORDER, - source_id=order.id, - ) - self.assertEqual(records.count(), 1) - self.assertEqual(records.first().balance_after, balance.balance) - - def test_payment_approval_is_idempotent(self): - order = services.create_payment_order( - merchant=self.merchant, - supplier=self.supplier, - payment_date=timezone.now().date(), - amount='75.00', - operator=self.operator, - ) - services.review_payment_order( - payment_order=order, - target_status=business_models.PaymentOrderStatusEnum.APPROVED, - reviewed_by=self.operator, - ) - services.review_payment_order( - payment_order=order, - target_status=business_models.PaymentOrderStatusEnum.APPROVED, - reviewed_by=self.operator, - ) - balance = business_models.SupplierBalance.objects.get( - merchant=self.merchant, - supplier=self.supplier, - ) - self.assertEqual(balance.balance, Decimal('-75.00')) - records = business_models.BalanceChangeRecord.objects.filter( - merchant=self.merchant, - source_type=business_models.BalanceChangeSourceEnum.PAYMENT_ORDER, - source_id=order.id, - ) - self.assertEqual(records.count(), 1) - self.assertEqual(records.first().balance_after, balance.balance) - - -class PurchaseOrderStockServiceTestCase(TestCase): - def setUp(self): - ( - self.merchant, - self.supplier, - self.warehouse_strict, - self.warehouse_relaxed, - self.product, - self.operator, - ) = create_basic_fixtures() - User = get_user_model() - self.user = User.objects.create_user(username='svc-user', password='pass123') - self.purchase_order = business_models.PurchaseOrder.objects.create( - merchant=self.merchant, - supplier=self.supplier, - purchase_date=timezone.now().date(), - operator=self.operator, - warehouse=self.warehouse_strict, - ) - self.items = [{'product_id': self.product.id, 'quantities': ['8']}] - - def test_service_calls_stock_flow_service(self): - with patch('business.services.StockFlowService') as mock_flow_cls: - mock_instance = mock_flow_cls.return_value - mock_instance.stock_in.return_value = (MagicMock(id=321), [], 2) - - payload = services.create_purchase_order_stock_entries_sync( - purchase_order_id=self.purchase_order.id, - warehouse_id=self.warehouse_strict.id, - items=self.items, - created_by_id=self.user.id, - ) - - mock_flow_cls.assert_called_once_with(merchant=self.merchant, created_by=self.user) - mock_instance.stock_in.assert_called_once_with( - warehouse_id=self.warehouse_strict.id, - source_type=stock_models.StockChangeSourceEnum.PURCHASE, - source_id=self.purchase_order.id, - items=[{'product_id': self.product.id, 'quantities': ['8']}], - ) - self.assertEqual(payload['purchase_order_id'], self.purchase_order.id) - self.assertEqual(payload['stock_change_record_id'], 321) - - -class SalesOrderStockServiceTestCase(TestCase): - def setUp(self): - ( - self.merchant, - self.customer, - self.warehouse_strict, - self.warehouse_relaxed, - self.warehouse_strict_out, - self.product, - self.operator, - ) = create_sales_fixtures() - User = get_user_model() - self.user = User.objects.create_user(username='sales-svc', password='pass123') - self.sales_order = business_models.SalesOrder.objects.create( - merchant=self.merchant, - customer=self.customer, - sales_date=timezone.now().date(), - operator=self.operator, - warehouse=self.warehouse_strict, - ) - self.items = [{'product_id': self.product.id, 'quantities': ['5']}] - - def test_service_calls_stock_out(self): - with patch('business.services.StockFlowService') as mock_flow_cls: - mock_instance = mock_flow_cls.return_value - mock_instance.stock_out.return_value = (MagicMock(id=654), [], 1) - - payload = services.create_sales_order_stock_entries_sync( - sales_order_id=self.sales_order.id, - warehouse_id=self.warehouse_strict.id, - items=self.items, - created_by_id=self.user.id, - ) - - mock_flow_cls.assert_called_once_with(merchant=self.merchant, created_by=self.user) - mock_instance.stock_out.assert_called_once_with( - warehouse_id=self.warehouse_strict.id, - source_type=stock_models.StockChangeSourceEnum.SALES, - source_id=self.sales_order.id, - items=[{'product_id': self.product.id, 'quantities': ['5']}], - ) - self.assertEqual(payload['sales_order_id'], self.sales_order.id) - self.assertEqual(payload['stock_change_record_id'], 654) - - def test_service_calls_stock_out_strict_mode(self): - strict_order = business_models.SalesOrder.objects.create( - merchant=self.merchant, - customer=self.customer, - sales_date=timezone.now().date(), - operator=self.operator, - warehouse=self.warehouse_strict_out, - ) - consume_items = [{'product_id': self.product.id, 'consume_detail_ids': [11, 12], 'quantity': 50}] - with patch('business.services.StockFlowService') as mock_flow_cls: - mock_instance = mock_flow_cls.return_value - mock_instance.stock_out.return_value = (MagicMock(id=987), [], 2) - - payload = services.create_sales_order_stock_entries_sync( - sales_order_id=strict_order.id, - warehouse_id=self.warehouse_strict_out.id, - items=consume_items, - created_by_id=self.user.id, - ) - - mock_flow_cls.assert_called_once_with(merchant=self.merchant, created_by=self.user) - mock_instance.stock_out.assert_called_once_with( - warehouse_id=self.warehouse_strict_out.id, - source_type=stock_models.StockChangeSourceEnum.SALES, - source_id=strict_order.id, - items=[{'product_id': self.product.id, 'consume_detail_ids': [11, 12], 'quantity': 50}], - ) - self.assertEqual(payload['sales_order_id'], strict_order.id) - self.assertEqual(payload['stock_change_record_id'], 987) - - -class SalesOrderConcurrencyTestCase(TransactionTestCase): - reset_sequences = True - - def setUp(self): - ( - self.merchant, - self.customer, - self.warehouse_strict, - self.warehouse_relaxed, - self.warehouse_strict_out, - self.product, - self.operator, - ) = create_sales_fixtures() - basic_models.MerchantSetting.objects.filter( - merchant=self.merchant, - key=basic_models.MerchantSettingKeyEnum.AUTO_CREATE_STOCK_CHANGE_TASKS, - ).update(val_bool=False) - User = get_user_model() - self.user = User.objects.create_user(username='concurrent', password='pass123') - self.sales_order = services.create_sales_order( - merchant=self.merchant, - customer=self.customer, - order_date=timezone.now().date(), - warehouse=self.warehouse_strict, - operator=self.operator, - items=[{'product_id': self.product.id, 'numbers': [5], 'price': '12', 'unit': '米'}], - created_by=self.user, - ) - - def test_concurrent_sales_order_approval_updates_balance_once(self): - def approve(): - # ThreadPoolExecutor 会复用线程;Django 的 DB connection 是线程局部的, - # 若不显式关闭,可能导致测试 DB 在 teardown 时仍被占用,无法 DROP。 - close_old_connections() - try: - services.review_sales_order( - sales_order_id=self.sales_order.id, - target_status=business_models.SalesOrderStatusEnum.APPROVED, - reviewed_by=self.user, - ) - finally: - connections.close_all() - - with ThreadPoolExecutor(max_workers=2) as executor: - futures = [executor.submit(approve) for _ in range(2)] - for future in futures: - future.result() - - balance = business_models.CustomerBalance.objects.get( - merchant=self.merchant, - customer=self.customer, - ) - self.assertEqual(balance.balance, self.sales_order.get_total_amount()) - records = business_models.BalanceChangeRecord.objects.filter( - merchant=self.merchant, - source_type=business_models.BalanceChangeSourceEnum.SALES_ORDER, - source_id=self.sales_order.id, - ) - self.assertEqual(records.count(), 1) - self.assertEqual(records.first().balance_after, balance.balance) - - def tearDown(self): - connections.close_all() - - -@override_settings( - CELERY_TASK_ALWAYS_EAGER=True, - CELERY_TASK_EAGER_PROPAGATES=True, -) -class PurchaseOrderStockTaskTestCase(TestCase): - def setUp(self): - self.purchase_order_id = 123 - self.warehouse_id = 456 - self.items = [{'product_id': 1, 'quantities': ['5']}] - - def test_task_delegates_to_service(self): - with patch('business.tasks.business_services.create_purchase_order_stock_entries_sync') as mock_sync: - mock_sync.return_value = {'purchase_order_id': self.purchase_order_id} - - async_result = tasks.create_purchase_order_stock_entries.delay( - purchase_order_id=self.purchase_order_id, - warehouse_id=self.warehouse_id, - items=self.items, - created_by_id=999, - ) - payload = async_result.get(timeout=5) - - mock_sync.assert_called_once_with( - purchase_order_id=self.purchase_order_id, - warehouse_id=self.warehouse_id, - items=self.items, - created_by_id=999, - ) - self.assertEqual(payload['purchase_order_id'], self.purchase_order_id) - self.assertIn('task_id', payload) - - -@override_settings( - CELERY_TASK_ALWAYS_EAGER=True, - CELERY_TASK_EAGER_PROPAGATES=True, -) -class SalesOrderStockTaskTestCase(TestCase): - def setUp(self): - self.sales_order_id = 321 - self.warehouse_id = 654 - self.items = [{'product_id': 2, 'quantities': ['4']}] - - def test_task_delegates_to_service(self): - with patch('business.tasks.business_services.create_sales_order_stock_entries_sync') as mock_sync: - mock_sync.return_value = {'sales_order_id': self.sales_order_id} - - async_result = tasks.create_sales_order_stock_entries.delay( - sales_order_id=self.sales_order_id, - warehouse_id=self.warehouse_id, - items=self.items, - created_by_id=777, - ) - payload = async_result.get(timeout=5) - - mock_sync.assert_called_once_with( - sales_order_id=self.sales_order_id, - warehouse_id=self.warehouse_id, - items=self.items, - created_by_id=777, - ) - self.assertEqual(payload['sales_order_id'], self.sales_order_id) - self.assertIn('task_id', payload) diff --git a/business/tests/__init__.py b/business/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/business/tests/fixtures.py b/business/tests/fixtures.py new file mode 100644 index 0000000..eb5b842 --- /dev/null +++ b/business/tests/fixtures.py @@ -0,0 +1,98 @@ +from basic_info import models as basic_models + + +def create_basic_fixtures(): + merchant = basic_models.Merchant.objects.create( + name='测试商户', + type=basic_models.MerchantTypeEnum.FACTORY, + ) + basic_models.MerchantSetting.objects.create( + merchant=merchant, + key=basic_models.MerchantSettingKeyEnum.AUTO_CREATE_STOCK_CHANGE_TASKS, + type=basic_models.MerchantSettingTypeEnum.BOOL, + val_bool=True, + ) + supplier = basic_models.Supplier.objects.create( + merchant=merchant, + name='测试供应商', + ) + warehouse_strict = basic_models.WareHouse.objects.create( + merchant=merchant, + name='严进仓', + mode=basic_models.WareHouseModeEnum.RESTRICT_IN, + ) + warehouse_relaxed = basic_models.WareHouse.objects.create( + merchant=merchant, + name='宽进仓', + mode=basic_models.WareHouseModeEnum.UNRESTRICTED, + ) + category = basic_models.ProductCategory.objects.create( + merchant=merchant, + name='面料', + product_prefix='FAB', + ) + product = basic_models.Product.objects.create( + merchant=merchant, + category=category, + name='测试面料', + human_id='FAB-001', + unit=basic_models.ProductUnitEnum.METER, + ) + operator = basic_models.Employee.objects.create( + merchant=merchant, + name='经办人', + status=basic_models.EmployeeStatusEnum.ACTIVE, + ) + return merchant, supplier, warehouse_strict, warehouse_relaxed, product, operator + + +def create_sales_fixtures(): + merchant = basic_models.Merchant.objects.create( + name='销售商户', + type=basic_models.MerchantTypeEnum.FACTORY, + ) + basic_models.MerchantSetting.objects.create( + merchant=merchant, + key=basic_models.MerchantSettingKeyEnum.AUTO_CREATE_STOCK_CHANGE_TASKS, + type=basic_models.MerchantSettingTypeEnum.BOOL, + val_bool=True, + ) + customer = basic_models.Customer.objects.create( + merchant=merchant, + name='客户A', + mobile='13800000000', + created_by=None, + ) + warehouse_strict = basic_models.WareHouse.objects.create( + merchant=merchant, + name='销售严进仓', + mode=basic_models.WareHouseModeEnum.RESTRICT_IN, + ) + warehouse_relaxed = basic_models.WareHouse.objects.create( + merchant=merchant, + name='销售宽进仓', + mode=basic_models.WareHouseModeEnum.UNRESTRICTED, + ) + warehouse_strict_out = basic_models.WareHouse.objects.create( + merchant=merchant, + name='销售严出仓', + mode=basic_models.WareHouseModeEnum.RESTRICT_IN_OUT, + ) + category = basic_models.ProductCategory.objects.create( + merchant=merchant, + name='销售品类', + product_prefix='SAL', + ) + product = basic_models.Product.objects.create( + merchant=merchant, + category=category, + name='销售面料', + human_id='SAL-001', + unit=basic_models.ProductUnitEnum.METER, + ) + operator = basic_models.Employee.objects.create( + merchant=merchant, + name='销售员', + status=basic_models.EmployeeStatusEnum.ACTIVE, + ) + return merchant, customer, warehouse_strict, warehouse_relaxed, warehouse_strict_out, product, operator diff --git a/business/tests/test_concurrency.py b/business/tests/test_concurrency.py new file mode 100644 index 0000000..7b8264c --- /dev/null +++ b/business/tests/test_concurrency.py @@ -0,0 +1,76 @@ +from concurrent.futures import ThreadPoolExecutor + +from django.contrib.auth import get_user_model +from django.db import close_old_connections, connections +from django.test import TransactionTestCase +from django.utils import timezone + +from basic_info import models as basic_models +from business import models as business_models, services + +from .fixtures import create_sales_fixtures + + +class SalesOrderConcurrencyTestCase(TransactionTestCase): + reset_sequences = True + + def setUp(self): + ( + self.merchant, + self.customer, + self.warehouse_strict, + self.warehouse_relaxed, + self.warehouse_strict_out, + self.product, + self.operator, + ) = create_sales_fixtures() + basic_models.MerchantSetting.objects.filter( + merchant=self.merchant, + key=basic_models.MerchantSettingKeyEnum.AUTO_CREATE_STOCK_CHANGE_TASKS, + ).update(val_bool=False) + User = get_user_model() + self.user = User.objects.create_user(username='concurrent', password='pass123') + self.sales_order = services.create_sales_order( + merchant=self.merchant, + customer=self.customer, + order_date=timezone.now().date(), + warehouse=self.warehouse_strict, + operator=self.operator, + items=[{'product_id': self.product.id, 'numbers': [5], 'price': '12', 'unit': '米'}], + created_by=self.user, + ) + + def test_concurrent_sales_order_approval_updates_balance_once(self): + def approve(): + # ThreadPoolExecutor 会复用线程;Django 的 DB connection 是线程局部的, + # 若不显式关闭,可能导致测试 DB 在 teardown 时仍被占用,无法 DROP。 + close_old_connections() + try: + services.review_sales_order( + sales_order_id=self.sales_order.id, + target_status=business_models.SalesOrderStatusEnum.APPROVED, + reviewed_by=self.user, + ) + finally: + connections.close_all() + + with ThreadPoolExecutor(max_workers=2) as executor: + futures = [executor.submit(approve) for _ in range(2)] + for future in futures: + future.result() + + balance = business_models.CustomerBalance.objects.get( + merchant=self.merchant, + customer=self.customer, + ) + self.assertEqual(balance.balance, self.sales_order.get_total_amount()) + records = business_models.BalanceChangeRecord.objects.filter( + merchant=self.merchant, + source_type=business_models.BalanceChangeSourceEnum.SALES_ORDER, + source_id=self.sales_order.id, + ) + self.assertEqual(records.count(), 1) + self.assertEqual(records.first().balance_after, balance.balance) + + def tearDown(self): + connections.close_all() diff --git a/business/tests/test_payment_receipt.py b/business/tests/test_payment_receipt.py new file mode 100644 index 0000000..1afa500 --- /dev/null +++ b/business/tests/test_payment_receipt.py @@ -0,0 +1,249 @@ +from decimal import Decimal + +from django.test import TestCase +from django.utils import timezone + +from basic_info import models as basic_models +from business import models as business_models, services + +from .fixtures import create_basic_fixtures + + +class PaymentReceiptServiceTestCase(TestCase): + def setUp(self): + ( + self.merchant, + self.supplier, + self.warehouse_strict, + self.warehouse_relaxed, + self.product, + self.operator, + ) = create_basic_fixtures() + self.customer = basic_models.Customer.objects.create( + merchant=self.merchant, + name='客户B', + mobile='13888888888', + created_by=None, + ) + self.bank_account = basic_models.BankAccount.objects.create( + merchant=self.merchant, + name='测试账户', + auto_number='BANK-SVC', + ) + + def test_create_payment_order_and_approve(self): + order = services.create_payment_order( + merchant=self.merchant, + supplier=self.supplier, + payment_date=timezone.now().date(), + amount='120.50', + discount_amount='20.50', + operator=self.operator, + remarks='付款测试', + bank_account=self.bank_account, + markup='服务层附言', + ) + self.assertEqual(order.status, business_models.PaymentOrderStatusEnum.PENDING) + self.assertEqual(order.bank_account, self.bank_account) + self.assertEqual(order.markup, '服务层附言') + self.assertEqual(order.discount_amount, Decimal('20.50')) + self.assertEqual(order.settlement_amount, Decimal('141.00')) + reviewed = services.review_payment_order( + payment_order=order, + target_status=business_models.PaymentOrderStatusEnum.APPROVED, + reviewed_by=self.operator, + ) + self.assertEqual(reviewed.status, business_models.PaymentOrderStatusEnum.APPROVED) + balance = business_models.SupplierBalance.objects.get( + merchant=self.merchant, + supplier=self.supplier, + ) + self.assertEqual(balance.balance, Decimal('-141.00')) + record = business_models.BalanceChangeRecord.objects.get( + merchant=self.merchant, + source_type=business_models.BalanceChangeSourceEnum.PAYMENT_ORDER, + source_id=order.id, + ) + self.assertEqual(record.direction, business_models.BalanceChangeDirectionEnum.DECREASE) + self.assertEqual(record.delta, Decimal('-141.00')) + self.assertEqual(record.balance_after, balance.balance) + with self.assertRaises(ValueError): + services.review_payment_order( + payment_order=order, + target_status=business_models.PaymentOrderStatusEnum.CANCELLED, + reviewed_by=self.operator, + ) + + def test_create_receipt_order_and_cancel(self): + order = services.create_receipt_order( + merchant=self.merchant, + customer=self.customer, + receipt_date=timezone.now().date(), + amount='80.00', + discount_amount='5', + operator=self.operator, + remarks='收款测试', + bank_account=self.bank_account, + markup='收款附言', + ) + self.assertEqual(order.bank_account, self.bank_account) + self.assertEqual(order.discount_amount, Decimal('5')) + self.assertEqual(order.settlement_amount, Decimal('85')) + cancelled = services.review_receipt_order( + receipt_order=order, + target_status=business_models.ReceiptOrderStatusEnum.CANCELLED, + reviewed_by=self.operator, + ) + self.assertEqual(cancelled.status, business_models.ReceiptOrderStatusEnum.CANCELLED) + self.assertFalse( + business_models.BalanceChangeRecord.objects.filter( + merchant=self.merchant, + source_type=business_models.BalanceChangeSourceEnum.RECEIPT_ORDER, + source_id=order.id, + ).exists() + ) + + def test_payment_amount_can_be_negative(self): + order = services.create_payment_order( + merchant=self.merchant, + supplier=self.supplier, + payment_date=timezone.now().date(), + amount='-25.50', + operator=self.operator, + ) + self.assertEqual(order.amount, Decimal('-25.50')) + + def test_payment_discount_can_exceed_amount(self): + order = services.create_payment_order( + merchant=self.merchant, + supplier=self.supplier, + payment_date=timezone.now().date(), + amount='50', + discount_amount='60', + operator=self.operator, + ) + self.assertEqual(order.settlement_amount, Decimal('110')) + + def test_receipt_discount_can_exceed_amount(self): + order = services.create_receipt_order( + merchant=self.merchant, + customer=self.customer, + receipt_date=timezone.now().date(), + amount='50', + discount_amount='60', + operator=self.operator, + ) + self.assertEqual(order.settlement_amount, Decimal('110')) + + def test_receipt_amount_can_be_negative(self): + order = services.create_receipt_order( + merchant=self.merchant, + customer=self.customer, + receipt_date=timezone.now().date(), + amount='-40', + operator=self.operator, + ) + self.assertEqual(order.amount, Decimal('-40')) + + def test_payment_order_rejects_foreign_bank_account(self): + other_merchant = basic_models.Merchant.objects.create( + name='无关商户', + type=basic_models.MerchantTypeEnum.FACTORY, + ) + foreign_bank = basic_models.BankAccount.objects.create( + merchant=other_merchant, + name='其他账户', + auto_number='BANK-FOREIGN', + ) + with self.assertRaises(ValueError): + services.create_payment_order( + merchant=self.merchant, + supplier=self.supplier, + payment_date=timezone.now().date(), + amount='10', + operator=self.operator, + bank_account=foreign_bank, + ) + + def test_receipt_order_rejects_foreign_bank_account(self): + other_merchant = basic_models.Merchant.objects.create( + name='第二商户', + type=basic_models.MerchantTypeEnum.FACTORY, + ) + foreign_bank = basic_models.BankAccount.objects.create( + merchant=other_merchant, + name='第二账户', + auto_number='BANK-SECOND', + ) + with self.assertRaises(ValueError): + services.create_receipt_order( + merchant=self.merchant, + customer=self.customer, + receipt_date=timezone.now().date(), + amount='10', + operator=self.operator, + bank_account=foreign_bank, + ) + + def test_receipt_approval_is_idempotent(self): + order = services.create_receipt_order( + merchant=self.merchant, + customer=self.customer, + receipt_date=timezone.now().date(), + amount='10', + operator=self.operator, + ) + services.review_receipt_order( + receipt_order=order, + target_status=business_models.ReceiptOrderStatusEnum.APPROVED, + reviewed_by=self.operator, + ) + reviewed_again = services.review_receipt_order( + receipt_order=order, + target_status=business_models.ReceiptOrderStatusEnum.APPROVED, + reviewed_by=self.operator, + ) + self.assertEqual(reviewed_again.status, business_models.ReceiptOrderStatusEnum.APPROVED) + balance = business_models.CustomerBalance.objects.get( + merchant=self.merchant, + customer=self.customer, + ) + self.assertEqual(balance.balance, Decimal('-10')) + records = business_models.BalanceChangeRecord.objects.filter( + merchant=self.merchant, + source_type=business_models.BalanceChangeSourceEnum.RECEIPT_ORDER, + source_id=order.id, + ) + self.assertEqual(records.count(), 1) + self.assertEqual(records.first().balance_after, balance.balance) + + def test_payment_approval_is_idempotent(self): + order = services.create_payment_order( + merchant=self.merchant, + supplier=self.supplier, + payment_date=timezone.now().date(), + amount='75.00', + operator=self.operator, + ) + services.review_payment_order( + payment_order=order, + target_status=business_models.PaymentOrderStatusEnum.APPROVED, + reviewed_by=self.operator, + ) + services.review_payment_order( + payment_order=order, + target_status=business_models.PaymentOrderStatusEnum.APPROVED, + reviewed_by=self.operator, + ) + balance = business_models.SupplierBalance.objects.get( + merchant=self.merchant, + supplier=self.supplier, + ) + self.assertEqual(balance.balance, Decimal('-75.00')) + records = business_models.BalanceChangeRecord.objects.filter( + merchant=self.merchant, + source_type=business_models.BalanceChangeSourceEnum.PAYMENT_ORDER, + source_id=order.id, + ) + self.assertEqual(records.count(), 1) + self.assertEqual(records.first().balance_after, balance.balance) diff --git a/business/tests/test_pre_sales_order.py b/business/tests/test_pre_sales_order.py new file mode 100644 index 0000000..de6bf5a --- /dev/null +++ b/business/tests/test_pre_sales_order.py @@ -0,0 +1,112 @@ +from django.test import TestCase + +from basic_info import models as basic_models +from business import models as business_models +from business import pre_order_services + +from .fixtures import create_sales_fixtures + + +class PreSalesOrderServiceTestCase(TestCase): + def setUp(self): + ( + self.merchant, + self.customer, + self.warehouse_strict, + self.warehouse_relaxed, + self.warehouse_strict_out, + self.product, + self.operator, + ) = create_sales_fixtures() + + def test_create_pre_sales_order_success(self): + pre_sales_order = pre_order_services.create_pre_sales_order( + merchant=self.merchant, + customer_id=self.customer.id, + warehouse_id=self.warehouse_relaxed.id, + created_by=self.operator, + kind=business_models.SalesOrderKindEnum.WHOLESALE, + items=[ + { + 'product_id': self.product.id, + 'quantity': '12.5', + 'unit': '米', + 'order_quantity': 8, + 'remarks': 'item-remarks', + } + ], + remarks='order-remarks', + ) + + self.assertEqual(pre_sales_order.merchant_id, self.merchant.id) + self.assertEqual(pre_sales_order.customer_id, self.customer.id) + self.assertEqual(pre_sales_order.warehouse_id, self.warehouse_relaxed.id) + self.assertEqual(pre_sales_order.created_by_id, self.operator.id) + self.assertEqual(pre_sales_order.items.count(), 1) + + item = pre_sales_order.items.first() + self.assertEqual(item.product_id, self.product.id) + self.assertEqual(item.unit, '米') + self.assertEqual(item.order_quantity, 8) + + def test_create_pre_sales_order_rejects_cross_merchant_customer(self): + other_merchant = basic_models.Merchant.objects.create( + name='其他商户', + type=basic_models.MerchantTypeEnum.FACTORY, + ) + other_customer = basic_models.Customer.objects.create( + merchant=other_merchant, + name='其他客户', + created_by=None, + ) + + with self.assertRaises(ValueError): + pre_order_services.create_pre_sales_order( + merchant=self.merchant, + customer_id=other_customer.id, + warehouse_id=self.warehouse_relaxed.id, + created_by=self.operator, + items=[{'product_id': self.product.id, 'quantity': '1', 'unit': '米'}], + ) + + def test_update_pre_sales_order_replaces_items(self): + pre_sales_order = pre_order_services.create_pre_sales_order( + merchant=self.merchant, + customer_id=self.customer.id, + warehouse_id=self.warehouse_relaxed.id, + created_by=self.operator, + items=[{'product_id': self.product.id, 'quantity': '10', 'unit': '米'}], + ) + + updated = pre_order_services.update_pre_sales_order( + pre_sales_order=pre_sales_order, + warehouse_id=self.warehouse_strict.id, + items=[ + { + 'product_id': self.product.id, + 'quantity': '20', + 'unit': '米', + 'order_quantity': 3, + } + ], + remarks='updated-remarks', + ) + + self.assertEqual(updated.warehouse_id, self.warehouse_strict.id) + self.assertEqual(updated.remarks, 'updated-remarks') + self.assertEqual(updated.items.count(), 1) + self.assertEqual(str(updated.items.first().quantity), '20.00') + self.assertEqual(updated.items.first().order_quantity, 3) + + def test_delete_pre_sales_order(self): + pre_sales_order = pre_order_services.create_pre_sales_order( + merchant=self.merchant, + customer_id=self.customer.id, + warehouse_id=self.warehouse_relaxed.id, + created_by=self.operator, + items=[{'product_id': self.product.id, 'quantity': '10', 'unit': '米'}], + ) + order_id = pre_sales_order.id + + pre_order_services.delete_pre_sales_order(pre_sales_order=pre_sales_order) + self.assertFalse(business_models.PreSalesOrder.objects.filter(id=order_id).exists()) diff --git a/business/tests/test_purchase_order.py b/business/tests/test_purchase_order.py new file mode 100644 index 0000000..78716ba --- /dev/null +++ b/business/tests/test_purchase_order.py @@ -0,0 +1,265 @@ +from typing import Any, Dict, List +from decimal import Decimal + +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.utils import timezone +from unittest.mock import patch + +from stock import models as stock_models +from business import models as business_models, services + +from .fixtures import create_basic_fixtures + + +class PurchaseOrderServiceTestCase(TestCase): + def setUp(self): + ( + self.merchant, + self.supplier, + self.warehouse_strict, + self.warehouse_relaxed, + self.product, + self.operator, + ) = create_basic_fixtures() + User = get_user_model() + self.user = User.objects.create_user(username='creator', password='pass123') + self.strict_items: List[Dict[str, Any]] = [ + {'product_id': self.product.id, 'numbers': [10, 5], 'price': '12.5', 'unit': '米'} + ] + self.relaxed_items: List[Dict[str, Any]] = [ + {'product_id': self.product.id, 'quantity': 120, 'num_of_rolls': 3, 'price': '10.0', 'unit': '米'} + ] + + def test_create_purchase_order_does_not_trigger_task_immediately(self): + with patch('business.services.create_purchase_order_stock_entries.delay') as mock_delay: + purchase_order = services.create_purchase_order( + merchant=self.merchant, + supplier=self.supplier, + order_date=timezone.now().date(), + warehouse=self.warehouse_strict, + operator=self.operator, + items=self.strict_items, + remarks='自动化测试', + created_by=self.user, + ) + + self.assertIsInstance(purchase_order, business_models.PurchaseOrder) + self.assertEqual(purchase_order.status, business_models.PurchaseOrderStatusEnum.PENDING) + self.assertEqual(purchase_order.items.count(), 1) + item = purchase_order.items.first() + self.assertEqual(item.quantity_of_rolls, '10,5') + self.assertEqual(item.split_quantity_of_rolls(), [10, 5]) + mock_delay.assert_not_called() + + def test_purchase_order_human_id_generation(self): + purchase_order = services.create_purchase_order( + merchant=self.merchant, + supplier=self.supplier, + order_date=timezone.now().date(), + warehouse=self.warehouse_strict, + operator=self.operator, + items=self.strict_items, + created_by=self.user, + ) + self.assertIsNotNone(purchase_order.human_id) + self.assertEqual( + purchase_order.human_id, + f"CG{purchase_order.created_at.strftime('%Y%m%d')}{purchase_order.id:06d}", + ) + + def test_review_purchase_order_approval_triggers_task(self): + purchase_order = services.create_purchase_order( + merchant=self.merchant, + supplier=self.supplier, + order_date=timezone.now().date(), + warehouse=self.warehouse_strict, + operator=self.operator, + items=self.strict_items, + created_by=self.user, + ) + + with patch('business.services.create_purchase_order_stock_entries.delay') as mock_delay: + reviewed = services.review_purchase_order( + purchase_order=purchase_order, + target_status=business_models.PurchaseOrderStatusEnum.APPROVED, + reviewed_by=self.user, + ) + + self.assertEqual(reviewed.status, business_models.PurchaseOrderStatusEnum.APPROVED) + mock_delay.assert_called_once_with( + purchase_order_id=purchase_order.id, + warehouse_id=self.warehouse_strict.id, + items=[{'product_id': self.product.id, 'quantities': ['10', '5']}], + created_by_id=self.user.id, + ) + balance = business_models.SupplierBalance.objects.get( + merchant=self.merchant, + supplier=self.supplier, + ) + self.assertEqual(balance.balance, purchase_order.get_total_amount()) + record = business_models.BalanceChangeRecord.objects.get( + merchant=self.merchant, + source_type=business_models.BalanceChangeSourceEnum.PURCHASE_ORDER, + source_id=purchase_order.id, + ) + self.assertEqual(record.target_type, business_models.BalanceChangeTargetEnum.SUPPLIER) + self.assertEqual(record.delta, purchase_order.get_total_amount()) + self.assertEqual(record.balance_after, balance.balance) + self.assertEqual(record.direction, business_models.BalanceChangeDirectionEnum.INCREASE) + + def test_create_purchase_order_without_items_raises(self): + with self.assertRaises(ValueError): + services.create_purchase_order( + merchant=self.merchant, + supplier=self.supplier, + order_date=timezone.now().date(), + warehouse=self.warehouse_strict, + operator=self.operator, + items=[], + ) + + def test_review_purchase_order_cancel_without_stock(self): + purchase_order = services.create_purchase_order( + merchant=self.merchant, + supplier=self.supplier, + order_date=timezone.now().date(), + warehouse=self.warehouse_relaxed, + operator=self.operator, + items=self.relaxed_items, + created_by=self.user, + ) + item = purchase_order.items.first() + self.assertIsNone(item.quantity_of_rolls) + self.assertEqual(item.split_quantity_of_rolls(), []) + + cancelled = services.review_purchase_order( + purchase_order=purchase_order, + target_status=business_models.PurchaseOrderStatusEnum.CANCELLED, + reviewed_by=self.user, + ) + self.assertEqual(cancelled.status, business_models.PurchaseOrderStatusEnum.CANCELLED) + self.assertFalse( + business_models.BalanceChangeRecord.objects.filter( + merchant=self.merchant, + source_type=business_models.BalanceChangeSourceEnum.PURCHASE_ORDER, + source_id=purchase_order.id, + ).exists() + ) + + def test_review_purchase_order_cancel_blocked_after_stock_created(self): + purchase_order = services.create_purchase_order( + merchant=self.merchant, + supplier=self.supplier, + order_date=timezone.now().date(), + warehouse=self.warehouse_relaxed, + operator=self.operator, + items=self.relaxed_items, + created_by=self.user, + ) + stock_models.StockChangeRecord.objects.create( + merchant=self.merchant, + type=stock_models.StockChangeTypeEnum.ADD, + warehouse=self.warehouse_relaxed, + source_type=stock_models.StockChangeSourceEnum.PURCHASE, + source_id=purchase_order.id, + ) + + with self.assertRaises(ValueError): + services.review_purchase_order( + purchase_order=purchase_order, + target_status=business_models.PurchaseOrderStatusEnum.CANCELLED, + reviewed_by=self.user, + ) + + def test_review_purchase_order_relaxed_mode_approval_payload(self): + purchase_order = services.create_purchase_order( + merchant=self.merchant, + supplier=self.supplier, + order_date=timezone.now().date(), + warehouse=self.warehouse_relaxed, + operator=self.operator, + items=self.relaxed_items, + created_by=self.user, + ) + + with patch('business.services.create_purchase_order_stock_entries.delay') as mock_delay: + services.review_purchase_order( + purchase_order=purchase_order, + target_status=business_models.PurchaseOrderStatusEnum.APPROVED, + reviewed_by=self.user, + ) + + mock_delay.assert_called_once_with( + purchase_order_id=purchase_order.id, + warehouse_id=self.warehouse_relaxed.id, + items=[{'product_id': self.product.id, 'value': '120.00', 'num_of_rolls': 3}], + created_by_id=self.user.id, + ) + + def test_update_purchase_order_success(self): + purchase_order = services.create_purchase_order( + merchant=self.merchant, + supplier=self.supplier, + order_date=timezone.now().date(), + warehouse=self.warehouse_relaxed, + operator=self.operator, + items=self.relaxed_items, + created_by=self.user, + ) + updated = services.update_purchase_order( + purchase_order=purchase_order, + warehouse=self.warehouse_strict, + items=self.strict_items, + remarks='updated', + ) + self.assertEqual(updated.remarks, 'updated') + self.assertEqual(updated.items.count(), 1) + self.assertEqual(updated.items.first().split_quantity_of_rolls(), [10, 5]) + + def test_update_purchase_order_rejects_non_pending(self): + purchase_order = services.create_purchase_order( + merchant=self.merchant, + supplier=self.supplier, + order_date=timezone.now().date(), + warehouse=self.warehouse_relaxed, + operator=self.operator, + items=self.relaxed_items, + created_by=self.user, + ) + purchase_order.status = business_models.PurchaseOrderStatusEnum.APPROVED + purchase_order.save(update_fields=['status']) + with self.assertRaises(ValueError): + services.update_purchase_order( + purchase_order=purchase_order, + items=self.relaxed_items, + ) + + def test_purchase_order_relaxed_mode_even_split(self): + purchase_order = services.create_purchase_order( + merchant=self.merchant, + supplier=self.supplier, + order_date=timezone.now().date(), + warehouse=self.warehouse_relaxed, + operator=self.operator, + items=[ + { + 'product_id': self.product.id, + 'quantity': 99, + 'num_of_rolls': 2, + 'price': '10.0', + 'unit': '米', + } + ], + created_by=self.user, + ) + stock_flow_items = services._build_stock_flow_items_from_order(purchase_order) + payload = services.create_purchase_order_stock_entries_sync( + purchase_order_id=purchase_order.id, + warehouse_id=self.warehouse_relaxed.id, + items=stock_flow_items, + created_by_id=self.user.id, + ) + record = stock_models.StockChangeRecord.objects.get(id=payload['stock_change_record_id']) + quantities = sorted([detail.quantity for detail in record.details.order_by('id')], reverse=True) + self.assertEqual(quantities, [Decimal('49.50'), Decimal('49.50')]) diff --git a/business/tests/test_purchase_return.py b/business/tests/test_purchase_return.py new file mode 100644 index 0000000..e559633 --- /dev/null +++ b/business/tests/test_purchase_return.py @@ -0,0 +1,183 @@ +from decimal import Decimal + +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.utils import timezone +from unittest.mock import patch + +from basic_info import models as basic_models +from stock import models as stock_models +from business import models as business_models, services + +from .fixtures import create_basic_fixtures + + +class PurchaseReturnServiceTestCase(TestCase): + def setUp(self): + ( + self.merchant, + self.supplier, + self.warehouse_strict, + self.warehouse_relaxed, + self.product, + self.operator, + ) = create_basic_fixtures() + self.warehouse_strict_out = basic_models.WareHouse.objects.create( + merchant=self.merchant, + name='采退严出仓', + mode=basic_models.WareHouseModeEnum.RESTRICT_IN_OUT, + ) + User = get_user_model() + self.user = User.objects.create_user(username='purchase-return', password='pass123') + self.strict_items = [ + {'product_id': self.product.id, 'numbers': [4, 2], 'price': '11.5', 'unit': '米'} + ] + self.strict_out_items = [ + { + 'product_id': self.product.id, + 'consume_detail_ids': [10, 11], + 'quantity': 30, + 'price': '12.0', + 'unit': '米', + } + ] + + def test_create_purchase_return_order_success(self): + order = services.create_purchase_return_order( + merchant=self.merchant, + supplier=self.supplier, + return_date=timezone.now().date(), + warehouse=self.warehouse_strict, + operator=self.operator, + items=self.strict_items, + created_by=self.user, + ) + self.assertEqual(order.status, business_models.PurchaseReturnStatusEnum.PENDING) + self.assertEqual(order.items.count(), 1) + item = order.items.first() + self.assertEqual(item.quantity_of_rolls, '4,2') + self.assertEqual(item.split_quantity_of_rolls(), [4, 2]) + + def test_purchase_return_order_human_id_generation(self): + order = services.create_purchase_return_order( + merchant=self.merchant, + supplier=self.supplier, + return_date=timezone.now().date(), + warehouse=self.warehouse_strict, + operator=self.operator, + items=self.strict_items, + created_by=self.user, + ) + self.assertIsNotNone(order.human_id) + self.assertEqual( + order.human_id, + f"CT{order.created_at.strftime('%Y%m%d')}{order.id:06d}", + ) + + def test_review_purchase_return_order_triggers_task_and_balance(self): + order = services.create_purchase_return_order( + merchant=self.merchant, + supplier=self.supplier, + return_date=timezone.now().date(), + warehouse=self.warehouse_relaxed, + operator=self.operator, + items=[{'product_id': self.product.id, 'quantity': 40, 'num_of_rolls': 2, 'price': '10.0'}], + created_by=self.user, + ) + with patch('business.services.create_purchase_return_order_stock_entries.delay') as mock_delay: + reviewed = services.review_purchase_return_order( + purchase_return_order=order, + target_status=business_models.PurchaseReturnStatusEnum.APPROVED, + reviewed_by=self.user, + ) + self.assertEqual(reviewed.status, business_models.PurchaseReturnStatusEnum.APPROVED) + mock_delay.assert_called_once() + balance = business_models.SupplierBalance.objects.get( + merchant=self.merchant, + supplier=self.supplier, + ) + self.assertEqual(balance.balance, Decimal('-400.00')) + record = business_models.BalanceChangeRecord.objects.get( + merchant=self.merchant, + source_type=business_models.BalanceChangeSourceEnum.PURCHASE_RETURN_ORDER, + source_id=order.id, + ) + self.assertEqual(record.direction, business_models.BalanceChangeDirectionEnum.DECREASE) + + def test_purchase_return_cancel_blocked_after_stock_created(self): + order = services.create_purchase_return_order( + merchant=self.merchant, + supplier=self.supplier, + return_date=timezone.now().date(), + warehouse=self.warehouse_relaxed, + operator=self.operator, + items=[{'product_id': self.product.id, 'quantity': 20, 'num_of_rolls': 2, 'price': '9.5'}], + ) + stock_models.StockChangeRecord.objects.create( + merchant=self.merchant, + type=stock_models.StockChangeTypeEnum.REMOVE, + warehouse=self.warehouse_relaxed, + source_type=stock_models.StockChangeSourceEnum.PURCHASE_RETURN, + source_id=order.id, + ) + with self.assertRaises(ValueError): + services.review_purchase_return_order( + purchase_return_order=order, + target_status=business_models.PurchaseReturnStatusEnum.CANCELLED, + ) + + def test_purchase_return_strict_out_requires_consume_ids(self): + payload = [ + { + 'product_id': self.product.id, + 'quantity': 10, + 'price': '8.5', + 'unit': '米', + } + ] + with self.assertRaises(ValueError): + services.create_purchase_return_order( + merchant=self.merchant, + supplier=self.supplier, + return_date=timezone.now().date(), + warehouse=self.warehouse_strict_out, + operator=self.operator, + items=payload, + ) + + def test_update_purchase_return_order_success(self): + order = services.create_purchase_return_order( + merchant=self.merchant, + supplier=self.supplier, + return_date=timezone.now().date(), + warehouse=self.warehouse_strict, + operator=self.operator, + items=self.strict_items, + created_by=self.user, + ) + updated = services.update_purchase_return_order( + purchase_return_order=order, + items=self.strict_items, + remarks='updated', + ) + self.assertEqual(updated.remarks, 'updated') + self.assertEqual(updated.items.count(), 1) + self.assertEqual(updated.items.first().split_quantity_of_rolls(), [4, 2]) + + def test_update_purchase_return_order_rejects_non_pending(self): + order = services.create_purchase_return_order( + merchant=self.merchant, + supplier=self.supplier, + return_date=timezone.now().date(), + warehouse=self.warehouse_strict, + operator=self.operator, + items=self.strict_items, + created_by=self.user, + ) + order.status = business_models.PurchaseReturnStatusEnum.APPROVED + order.save(update_fields=['status']) + with self.assertRaises(ValueError): + services.update_purchase_return_order( + purchase_return_order=order, + items=self.strict_items, + ) diff --git a/business/tests/test_sales_order.py b/business/tests/test_sales_order.py new file mode 100644 index 0000000..1ddde96 --- /dev/null +++ b/business/tests/test_sales_order.py @@ -0,0 +1,231 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.utils import timezone +from unittest.mock import patch + +from stock import models as stock_models +from business import models as business_models, services + +from .fixtures import create_sales_fixtures + + +class SalesOrderServiceTestCase(TestCase): + def setUp(self): + ( + self.merchant, + self.customer, + self.warehouse_strict, + self.warehouse_relaxed, + self.warehouse_strict_out, + self.product, + self.operator, + ) = create_sales_fixtures() + User = get_user_model() + self.user = User.objects.create_user(username='sales-creator', password='pass123') + self.strict_items = [ + {'product_id': self.product.id, 'numbers': [6, 4], 'price': '30.5', 'unit': '米'} + ] + self.relaxed_items = [ + {'product_id': self.product.id, 'quantity': 80, 'num_of_rolls': 2, 'price': '28.0', 'unit': '米'} + ] + self.strict_out_items = [ + { + 'product_id': self.product.id, + 'consume_detail_ids': [1, 2], + 'quantity': 40, + 'price': '32.0', + 'unit': '米', + } + ] + + def test_create_sales_order_success(self): + with patch('business.services.create_sales_order_stock_entries.delay') as mock_delay: + sales_order = services.create_sales_order( + merchant=self.merchant, + customer=self.customer, + order_date=timezone.now().date(), + warehouse=self.warehouse_strict, + operator=self.operator, + items=self.strict_items, + remarks='销售测试', + created_by=self.user, + ) + + self.assertIsInstance(sales_order, business_models.SalesOrder) + self.assertEqual(sales_order.status, business_models.SalesOrderStatusEnum.PENDING) + self.assertEqual(sales_order.items.count(), 1) + item = sales_order.items.first() + self.assertEqual(item.quantity_of_rolls, '6,4') + self.assertEqual(item.split_quantity_of_rolls(), [6, 4]) + mock_delay.assert_not_called() + + def test_sales_order_human_id_generation(self): + sales_order = services.create_sales_order( + merchant=self.merchant, + customer=self.customer, + order_date=timezone.now().date(), + warehouse=self.warehouse_strict, + operator=self.operator, + items=self.strict_items, + created_by=self.user, + ) + + self.assertIsNotNone(sales_order.human_id) + self.assertTrue(len(sales_order.human_id) >= 14) + self.assertEqual( + sales_order.human_id, + f"XS{sales_order.created_at.strftime('%Y%m%d')}{sales_order.id:06d}", + ) + + def test_review_sales_order_triggers_task(self): + sales_order = services.create_sales_order( + merchant=self.merchant, + customer=self.customer, + order_date=timezone.now().date(), + warehouse=self.warehouse_strict, + operator=self.operator, + items=self.strict_items, + created_by=self.user, + ) + with patch('business.services.create_sales_order_stock_entries.delay') as mock_delay: + reviewed = services.review_sales_order( + sales_order=sales_order, + target_status=business_models.SalesOrderStatusEnum.APPROVED, + reviewed_by=self.user, + ) + self.assertEqual(reviewed.status, business_models.SalesOrderStatusEnum.APPROVED) + mock_delay.assert_called_once_with( + sales_order_id=sales_order.id, + warehouse_id=self.warehouse_strict.id, + items=[{'product_id': self.product.id, 'quantities': ['6', '4']}], + created_by_id=self.user.id, + ) + balance = business_models.CustomerBalance.objects.get( + merchant=self.merchant, + customer=self.customer, + ) + self.assertEqual(balance.balance, sales_order.get_total_amount()) + record = business_models.BalanceChangeRecord.objects.get( + merchant=self.merchant, + source_type=business_models.BalanceChangeSourceEnum.SALES_ORDER, + source_id=sales_order.id, + ) + self.assertEqual(record.target_type, business_models.BalanceChangeTargetEnum.CUSTOMER) + self.assertEqual(record.delta, sales_order.get_total_amount()) + self.assertEqual(record.balance_after, balance.balance) + self.assertEqual(record.direction, business_models.BalanceChangeDirectionEnum.INCREASE) + + def test_sales_order_cancel_blocked_after_stock_created(self): + sales_order = services.create_sales_order( + merchant=self.merchant, + customer=self.customer, + order_date=timezone.now().date(), + warehouse=self.warehouse_relaxed, + operator=self.operator, + items=self.relaxed_items, + created_by=self.user, + ) + stock_models.StockChangeRecord.objects.create( + merchant=self.merchant, + type=stock_models.StockChangeTypeEnum.REMOVE, + warehouse=self.warehouse_relaxed, + source_type=stock_models.StockChangeSourceEnum.SALES, + source_id=sales_order.id, + ) + with self.assertRaises(ValueError): + services.review_sales_order( + sales_order=sales_order, + target_status=business_models.SalesOrderStatusEnum.CANCELLED, + reviewed_by=self.user, + ) + self.assertFalse( + business_models.BalanceChangeRecord.objects.filter( + merchant=self.merchant, + source_type=business_models.BalanceChangeSourceEnum.SALES_ORDER, + source_id=sales_order.id, + ).exists() + ) + + def test_sales_order_requires_consume_ids_for_strict_out(self): + with self.assertRaises(ValueError): + services.create_sales_order( + merchant=self.merchant, + customer=self.customer, + order_date=timezone.now().date(), + warehouse=self.warehouse_strict_out, + operator=self.operator, + items=[{'product_id': self.product.id, 'quantity': 10, 'price': '20', 'unit': '米'}], + created_by=self.user, + ) + + def test_sales_order_stores_consume_ids(self): + sales_order = services.create_sales_order( + merchant=self.merchant, + customer=self.customer, + order_date=timezone.now().date(), + warehouse=self.warehouse_strict_out, + operator=self.operator, + items=self.strict_out_items, + created_by=self.user, + ) + item = sales_order.items.first() + self.assertEqual(item.consume_detail_ids, '1,2') + + def test_sales_order_outgoing_missing_consume_ids_raises(self): + payload = [ + { + 'product_id': self.product.id, + 'quantity': 30, + 'price': '25', + 'unit': '米', + } + ] + with self.assertRaises(ValueError): + services.create_sales_order( + merchant=self.merchant, + customer=self.customer, + order_date=timezone.now().date(), + warehouse=self.warehouse_strict_out, + operator=self.operator, + items=payload, + created_by=self.user, + ) + + def test_update_sales_order_success(self): + sales_order = services.create_sales_order( + merchant=self.merchant, + customer=self.customer, + order_date=timezone.now().date(), + warehouse=self.warehouse_strict, + operator=self.operator, + items=self.strict_items, + created_by=self.user, + ) + updated = services.update_sales_order( + sales_order=sales_order, + warehouse=self.warehouse_relaxed, + items=self.relaxed_items, + remarks='updated', + ) + self.assertEqual(updated.remarks, 'updated') + self.assertEqual(updated.items.count(), 1) + item = updated.items.first() + self.assertEqual(item.split_quantity_of_rolls(), []) + + def test_update_sales_order_rejects_non_pending(self): + sales_order = services.create_sales_order( + merchant=self.merchant, + customer=self.customer, + order_date=timezone.now().date(), + warehouse=self.warehouse_strict, + operator=self.operator, + items=self.strict_items, + created_by=self.user, + ) + sales_order.status = business_models.SalesOrderStatusEnum.APPROVED + sales_order.save(update_fields=['status']) + with self.assertRaises(ValueError): + services.update_sales_order( + sales_order=sales_order, + items=self.strict_items, + ) diff --git a/business/tests/test_sales_return.py b/business/tests/test_sales_return.py new file mode 100644 index 0000000..aff2dfe --- /dev/null +++ b/business/tests/test_sales_return.py @@ -0,0 +1,182 @@ +from decimal import Decimal + +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.utils import timezone +from unittest.mock import patch + +from stock import models as stock_models +from business import models as business_models, services + +from .fixtures import create_sales_fixtures + + +class SalesReturnServiceTestCase(TestCase): + def setUp(self): + ( + self.merchant, + self.customer, + self.warehouse_strict, + self.warehouse_relaxed, + self.warehouse_strict_out, + self.product, + self.operator, + ) = create_sales_fixtures() + User = get_user_model() + self.user = User.objects.create_user(username='sales-return', password='pass123') + self.strict_items = [ + {'product_id': self.product.id, 'numbers': [6, 2], 'price': '20.0', 'unit': '米'} + ] + + def test_create_sales_return_order_success(self): + order = services.create_sales_return_order( + merchant=self.merchant, + customer=self.customer, + return_date=timezone.now().date(), + warehouse=self.warehouse_relaxed, + operator=self.operator, + items=[{'product_id': self.product.id, 'quantity': 50, 'num_of_rolls': 2, 'price': '19.5'}], + ) + self.assertEqual(order.status, business_models.SalesReturnStatusEnum.PENDING) + item = order.items.first() + self.assertEqual(item.split_quantity_of_rolls(), []) + + def test_sales_return_order_human_id_generation(self): + order = services.create_sales_return_order( + merchant=self.merchant, + customer=self.customer, + return_date=timezone.now().date(), + warehouse=self.warehouse_relaxed, + operator=self.operator, + items=[{'product_id': self.product.id, 'quantity': 50, 'num_of_rolls': 2, 'price': '19.5'}], + created_by=self.user, + ) + self.assertIsNotNone(order.human_id) + self.assertEqual( + order.human_id, + f"XT{order.created_at.strftime('%Y%m%d')}{order.id:06d}", + ) + + def test_review_sales_return_order_updates_balance(self): + order = services.create_sales_return_order( + merchant=self.merchant, + customer=self.customer, + return_date=timezone.now().date(), + warehouse=self.warehouse_strict, + operator=self.operator, + items=self.strict_items, + created_by=self.user, + ) + self.assertEqual(order.items.first().split_quantity_of_rolls(), [6, 2]) + with patch('business.services.create_sales_return_order_stock_entries.delay') as mock_delay: + reviewed = services.review_sales_return_order( + sales_return_order=order, + target_status=business_models.SalesReturnStatusEnum.APPROVED, + reviewed_by=self.user, + ) + self.assertEqual(reviewed.status, business_models.SalesReturnStatusEnum.APPROVED) + mock_delay.assert_called_once() + balance = business_models.CustomerBalance.objects.get( + merchant=self.merchant, + customer=self.customer, + ) + self.assertLess(balance.balance, Decimal('0')) + record = business_models.BalanceChangeRecord.objects.get( + merchant=self.merchant, + source_type=business_models.BalanceChangeSourceEnum.SALES_RETURN_ORDER, + source_id=order.id, + ) + self.assertEqual(record.direction, business_models.BalanceChangeDirectionEnum.DECREASE) + + def test_sales_return_cancel_blocked_after_stock_created(self): + order = services.create_sales_return_order( + merchant=self.merchant, + customer=self.customer, + return_date=timezone.now().date(), + warehouse=self.warehouse_relaxed, + operator=self.operator, + items=[{'product_id': self.product.id, 'quantity': 15, 'num_of_rolls': 1, 'price': '18.0'}], + ) + stock_models.StockChangeRecord.objects.create( + merchant=self.merchant, + type=stock_models.StockChangeTypeEnum.ADD, + warehouse=self.warehouse_relaxed, + source_type=stock_models.StockChangeSourceEnum.SALES_RETURN, + source_id=order.id, + ) + with self.assertRaises(ValueError): + services.review_sales_return_order( + sales_return_order=order, + target_status=business_models.SalesReturnStatusEnum.CANCELLED, + ) + + def test_sales_return_relaxed_mode_stock_payload_preserves_rolls(self): + order = services.create_sales_return_order( + merchant=self.merchant, + customer=self.customer, + return_date=timezone.now().date(), + warehouse=self.warehouse_relaxed, + operator=self.operator, + items=[{'product_id': self.product.id, 'quantity': 100, 'num_of_rolls': 1, 'price': '22.0'}], + ) + stock_flow_items = services._build_stock_flow_items_from_order(order) + self.assertEqual(len(stock_flow_items), 1) + self.assertEqual(stock_flow_items[0]['num_of_rolls'], 1) + + def test_sales_return_relaxed_mode_even_split(self): + order = services.create_sales_return_order( + merchant=self.merchant, + customer=self.customer, + return_date=timezone.now().date(), + warehouse=self.warehouse_relaxed, + operator=self.operator, + items=[{'product_id': self.product.id, 'quantity': 100, 'num_of_rolls': 3, 'price': '22.0'}], + created_by=self.user, + ) + stock_flow_items = services._build_stock_flow_items_from_order(order) + payload = services.create_sales_return_order_stock_entries_sync( + sales_return_order_id=order.id, + warehouse_id=self.warehouse_relaxed.id, + items=stock_flow_items, + created_by_id=self.user.id, + ) + record = stock_models.StockChangeRecord.objects.get(id=payload['stock_change_record_id']) + quantities = sorted([detail.quantity for detail in record.details.order_by('id')], reverse=True) + self.assertEqual(quantities, [Decimal('33.34'), Decimal('33.33'), Decimal('33.33')]) + + def test_update_sales_return_order_success(self): + order = services.create_sales_return_order( + merchant=self.merchant, + customer=self.customer, + return_date=timezone.now().date(), + warehouse=self.warehouse_strict, + operator=self.operator, + items=self.strict_items, + created_by=self.user, + ) + updated = services.update_sales_return_order( + sales_return_order=order, + items=self.strict_items, + remarks='updated', + ) + self.assertEqual(updated.remarks, 'updated') + self.assertEqual(updated.items.count(), 1) + self.assertEqual(updated.items.first().split_quantity_of_rolls(), [6, 2]) + + def test_update_sales_return_order_rejects_non_pending(self): + order = services.create_sales_return_order( + merchant=self.merchant, + customer=self.customer, + return_date=timezone.now().date(), + warehouse=self.warehouse_strict, + operator=self.operator, + items=self.strict_items, + created_by=self.user, + ) + order.status = business_models.SalesReturnStatusEnum.APPROVED + order.save(update_fields=['status']) + with self.assertRaises(ValueError): + services.update_sales_return_order( + sales_return_order=order, + items=self.strict_items, + ) diff --git a/business/tests/test_stock_services.py b/business/tests/test_stock_services.py new file mode 100644 index 0000000..e1ff254 --- /dev/null +++ b/business/tests/test_stock_services.py @@ -0,0 +1,128 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.utils import timezone +from unittest.mock import patch, MagicMock + +from stock import models as stock_models +from business import models as business_models, services + +from .fixtures import create_basic_fixtures, create_sales_fixtures + + +class PurchaseOrderStockServiceTestCase(TestCase): + def setUp(self): + ( + self.merchant, + self.supplier, + self.warehouse_strict, + self.warehouse_relaxed, + self.product, + self.operator, + ) = create_basic_fixtures() + User = get_user_model() + self.user = User.objects.create_user(username='svc-user', password='pass123') + self.purchase_order = business_models.PurchaseOrder.objects.create( + merchant=self.merchant, + supplier=self.supplier, + purchase_date=timezone.now().date(), + operator=self.operator, + warehouse=self.warehouse_strict, + ) + self.items = [{'product_id': self.product.id, 'quantities': ['8']}] + + def test_service_calls_stock_flow_service(self): + with patch('business.services.StockFlowService') as mock_flow_cls: + mock_instance = mock_flow_cls.return_value + mock_instance.stock_in.return_value = (MagicMock(id=321), [], 2) + + payload = services.create_purchase_order_stock_entries_sync( + purchase_order_id=self.purchase_order.id, + warehouse_id=self.warehouse_strict.id, + items=self.items, + created_by_id=self.user.id, + ) + + mock_flow_cls.assert_called_once_with(merchant=self.merchant, created_by=self.user) + mock_instance.stock_in.assert_called_once_with( + warehouse_id=self.warehouse_strict.id, + source_type=stock_models.StockChangeSourceEnum.PURCHASE, + source_id=self.purchase_order.id, + items=[{'product_id': self.product.id, 'quantities': ['8']}], + ) + self.assertEqual(payload['purchase_order_id'], self.purchase_order.id) + self.assertEqual(payload['stock_change_record_id'], 321) + + +class SalesOrderStockServiceTestCase(TestCase): + def setUp(self): + ( + self.merchant, + self.customer, + self.warehouse_strict, + self.warehouse_relaxed, + self.warehouse_strict_out, + self.product, + self.operator, + ) = create_sales_fixtures() + User = get_user_model() + self.user = User.objects.create_user(username='sales-svc', password='pass123') + self.sales_order = business_models.SalesOrder.objects.create( + merchant=self.merchant, + customer=self.customer, + sales_date=timezone.now().date(), + operator=self.operator, + warehouse=self.warehouse_strict, + ) + self.items = [{'product_id': self.product.id, 'quantities': ['5']}] + + def test_service_calls_stock_out(self): + with patch('business.services.StockFlowService') as mock_flow_cls: + mock_instance = mock_flow_cls.return_value + mock_instance.stock_out.return_value = (MagicMock(id=654), [], 1) + + payload = services.create_sales_order_stock_entries_sync( + sales_order_id=self.sales_order.id, + warehouse_id=self.warehouse_strict.id, + items=self.items, + created_by_id=self.user.id, + ) + + mock_flow_cls.assert_called_once_with(merchant=self.merchant, created_by=self.user) + mock_instance.stock_out.assert_called_once_with( + warehouse_id=self.warehouse_strict.id, + source_type=stock_models.StockChangeSourceEnum.SALES, + source_id=self.sales_order.id, + items=[{'product_id': self.product.id, 'quantities': ['5']}], + ) + self.assertEqual(payload['sales_order_id'], self.sales_order.id) + self.assertEqual(payload['stock_change_record_id'], 654) + + def test_service_calls_stock_out_strict_mode(self): + strict_order = business_models.SalesOrder.objects.create( + merchant=self.merchant, + customer=self.customer, + sales_date=timezone.now().date(), + operator=self.operator, + warehouse=self.warehouse_strict_out, + ) + consume_items = [{'product_id': self.product.id, 'consume_detail_ids': [11, 12], 'quantity': 50}] + with patch('business.services.StockFlowService') as mock_flow_cls: + mock_instance = mock_flow_cls.return_value + mock_instance.stock_out.return_value = (MagicMock(id=987), [], 2) + + payload = services.create_sales_order_stock_entries_sync( + sales_order_id=strict_order.id, + warehouse_id=self.warehouse_strict_out.id, + items=consume_items, + created_by_id=self.user.id, + ) + + mock_flow_cls.assert_called_once_with(merchant=self.merchant, created_by=self.user) + mock_instance.stock_out.assert_called_once_with( + warehouse_id=self.warehouse_strict_out.id, + source_type=stock_models.StockChangeSourceEnum.SALES, + source_id=strict_order.id, + items=[{'product_id': self.product.id, 'consume_detail_ids': [11, 12], 'quantity': 50}], + ) + self.assertEqual(payload['sales_order_id'], strict_order.id) + self.assertEqual(payload['stock_change_record_id'], 987) diff --git a/business/tests/test_tasks.py b/business/tests/test_tasks.py new file mode 100644 index 0000000..0a6360b --- /dev/null +++ b/business/tests/test_tasks.py @@ -0,0 +1,68 @@ +from django.test import TestCase, override_settings +from unittest.mock import patch + +from business import tasks + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CELERY_TASK_EAGER_PROPAGATES=True, +) +class PurchaseOrderStockTaskTestCase(TestCase): + def setUp(self): + self.purchase_order_id = 123 + self.warehouse_id = 456 + self.items = [{'product_id': 1, 'quantities': ['5']}] + + def test_task_delegates_to_service(self): + with patch('business.tasks.business_services.create_purchase_order_stock_entries_sync') as mock_sync: + mock_sync.return_value = {'purchase_order_id': self.purchase_order_id} + + async_result = tasks.create_purchase_order_stock_entries.delay( + purchase_order_id=self.purchase_order_id, + warehouse_id=self.warehouse_id, + items=self.items, + created_by_id=999, + ) + payload = async_result.get(timeout=5) + + mock_sync.assert_called_once_with( + purchase_order_id=self.purchase_order_id, + warehouse_id=self.warehouse_id, + items=self.items, + created_by_id=999, + ) + self.assertEqual(payload['purchase_order_id'], self.purchase_order_id) + self.assertIn('task_id', payload) + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CELERY_TASK_EAGER_PROPAGATES=True, +) +class SalesOrderStockTaskTestCase(TestCase): + def setUp(self): + self.sales_order_id = 321 + self.warehouse_id = 654 + self.items = [{'product_id': 2, 'quantities': ['4']}] + + def test_task_delegates_to_service(self): + with patch('business.tasks.business_services.create_sales_order_stock_entries_sync') as mock_sync: + mock_sync.return_value = {'sales_order_id': self.sales_order_id} + + async_result = tasks.create_sales_order_stock_entries.delay( + sales_order_id=self.sales_order_id, + warehouse_id=self.warehouse_id, + items=self.items, + created_by_id=777, + ) + payload = async_result.get(timeout=5) + + mock_sync.assert_called_once_with( + sales_order_id=self.sales_order_id, + warehouse_id=self.warehouse_id, + items=self.items, + created_by_id=777, + ) + self.assertEqual(payload['sales_order_id'], self.sales_order_id) + self.assertIn('task_id', payload)