1
0
forked from erp-dev/erp

feat: added pre sales order in business module, and explode tests of business module

This commit is contained in:
2026-01-31 22:17:02 +08:00
parent 1af18c9388
commit a093174913
19 changed files with 2386 additions and 1404 deletions

View File

@@ -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())

View File

@@ -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/<int:pk>/', sales_return_views.SalesReturnOrderDetailView.as_view(), name='sales_return_order_detail'),
path('sales-return-orders/<int:pk>/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/<int:pk>/', 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/<int:pk>/review/', payment_views.PaymentOrderReviewView.as_view(), name='payment_order_review'),
path('receipt-orders/', receipt_views.ReceiptOrderView.as_view(), name='receipt_orders'),

View File

@@ -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)

View File

@@ -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': '预销售单明细',
},
),
]

View File

@@ -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, '审批通过'

View File

@@ -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()

File diff suppressed because it is too large Load Diff

View File

View File

@@ -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

View File

@@ -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()

View File

@@ -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)

View File

@@ -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())

View File

@@ -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')])

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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)