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.purchase_return import views as purchase_return_views
from .views.business.sales_return import views as sales_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.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.stock_change_views.snapshot import StockSnapshotListView
from .views.printing.views import PrintingOrderViewSet, PrintingJobViewSet, PlateOrderViewSet from .views.printing.views import PrintingOrderViewSet, PrintingJobViewSet, PlateOrderViewSet
from .views.upload import UploadFileViewSet 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.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>/', 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('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/', payment_views.PaymentOrderView.as_view(), name='payment_orders'),
path('payment-orders/<int:pk>/review/', payment_views.PaymentOrderReviewView.as_view(), name='payment_order_review'), 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'), 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 decimal import Decimal
from typing import List from typing import List
from django.db import models from django.db import models
from django.core.exceptions import ValidationError
from flower.common import ModelBase from flower.common import ModelBase
from basic_info import models as basic_info_models from basic_info import models as basic_info_models
@@ -360,6 +361,146 @@ class SalesOrderItem(ModelBase):
return round(self.price * self.real_quantity(), 2) 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): class PurchaseReturnStatusEnum(models.IntegerChoices):
PENDING = 1, '审批中' PENDING = 1, '审批中'
APPROVED = 2, '审批通过' 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)