forked from erp-dev/erp
feat: added n-to-1 relation between sales_order_item and printing_job model
This commit is contained in:
@@ -150,9 +150,9 @@ class SalesOrderItemAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
'id', 'sales_order', 'product',
|
||||
'quantity', 'unit', 'price', 'batch_number',
|
||||
'_total_amount', 'empty_diff_percent',
|
||||
'_real_quantity', '_diff_quantity',
|
||||
'num_of_rolls',
|
||||
'quantity_of_rolls', '_total_amount',
|
||||
'empty_diff_percent', '_real_quantity',
|
||||
'_diff_quantity', 'num_of_rolls',
|
||||
)
|
||||
search_fields = ('sales_order__id', 'product__name')
|
||||
list_filter = ('sales_order__operator', 'sales_order__warehouse', 'created_at')
|
||||
|
||||
@@ -15,7 +15,7 @@ class Migration(migrations.Migration):
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=models.SET_NULL,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='sales_order_items',
|
||||
to='printing.printingjob',
|
||||
verbose_name='关联印染任务',
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 5.2.8 on 2025-12-11 04:51
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('business', '0017_salesorderitem_printing_job'),
|
||||
('printing', '0022_printingjob_work_state'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='salesorderitem',
|
||||
name='printing_job',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='sales_order_items', to='printing.printingjob', verbose_name='关联印染任务'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.8 on 2025-12-11 09:13
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('business', '0018_alter_salesorderitem_printing_job'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='salesorderitem',
|
||||
name='quantity_of_rolls',
|
||||
field=models.TextField(blank=True, null=True, verbose_name='各条数数量'),
|
||||
),
|
||||
]
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import List
|
||||
from django.db import models
|
||||
from flower.common import ModelBase
|
||||
from basic_info import models as basic_info_models
|
||||
@@ -193,6 +194,11 @@ class PurchaseOrderItem(ModelBase):
|
||||
def total_amount(self):
|
||||
return round(self.price * self.real_quantity(), 2)
|
||||
|
||||
def split_quantity_of_rolls(self) -> List[int]:
|
||||
if self.quantity_of_rolls:
|
||||
return [int(value) for value in self.quantity_of_rolls.split(',') if value.strip()]
|
||||
return []
|
||||
|
||||
|
||||
class SalesOrderKindEnum(models.IntegerChoices):
|
||||
"""销售单类型"""
|
||||
@@ -280,7 +286,7 @@ class SalesOrderItem(ModelBase):
|
||||
)
|
||||
printing_job = models.ForeignKey(
|
||||
'printing.PrintingJob',
|
||||
on_delete=models.SET_NULL,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='sales_order_items',
|
||||
null=True,
|
||||
blank=True,
|
||||
@@ -296,8 +302,7 @@ class SalesOrderItem(ModelBase):
|
||||
decimal_places=2,
|
||||
verbose_name='空差百分比',
|
||||
)
|
||||
quantity_of_rolls = models.CharField(
|
||||
max_length=255,
|
||||
quantity_of_rolls = models.TextField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='各条数数量',
|
||||
@@ -316,6 +321,11 @@ class SalesOrderItem(ModelBase):
|
||||
verbose_name = '销售单明细'
|
||||
verbose_name_plural = '销售单明细'
|
||||
|
||||
def split_quantity_of_rolls(self) -> List[int]:
|
||||
if self.quantity_of_rolls:
|
||||
return [int(value) for value in self.quantity_of_rolls.split(',') if value.strip()]
|
||||
return []
|
||||
|
||||
def real_quantity(self):
|
||||
return round(self.quantity * (1 - self.empty_diff_percent / 100), 2)
|
||||
|
||||
@@ -425,6 +435,11 @@ class PurchaseReturnOrderItem(ModelBase):
|
||||
verbose_name = '采购退货明细'
|
||||
verbose_name_plural = '采购退货明细'
|
||||
|
||||
def split_quantity_of_rolls(self) -> List[int]:
|
||||
if self.quantity_of_rolls:
|
||||
return [int(value) for value in self.quantity_of_rolls.split(',') if value.strip()]
|
||||
return []
|
||||
|
||||
def real_quantity(self):
|
||||
return round(self.quantity * (1 - self.empty_diff_percent / 100), 2)
|
||||
|
||||
@@ -542,6 +557,11 @@ class SalesReturnOrderItem(ModelBase):
|
||||
|
||||
def total_amount(self):
|
||||
return round(self.price * self.real_quantity(), 2)
|
||||
|
||||
def split_quantity_of_rolls(self) -> List[int]:
|
||||
if self.quantity_of_rolls:
|
||||
return [int(value) for value in self.quantity_of_rolls.split(',') if value.strip()]
|
||||
return []
|
||||
|
||||
|
||||
class PaymentOrderStatusEnum(models.IntegerChoices):
|
||||
|
||||
@@ -4,7 +4,7 @@ import logging
|
||||
from collections import OrderedDict
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||||
from typing import Any, Dict, Iterable, List, Tuple
|
||||
from typing import Any, Dict, Iterable, List, Tuple, Optional
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import transaction
|
||||
@@ -260,6 +260,7 @@ def create_sales_order(
|
||||
warehouse=warehouse,
|
||||
items=items,
|
||||
is_outgoing=True,
|
||||
customer=customer,
|
||||
)
|
||||
|
||||
with transaction.atomic():
|
||||
@@ -286,6 +287,7 @@ def create_sales_order(
|
||||
consume_detail_ids=item_data.get('consume_detail_ids'),
|
||||
batch_number=item_data.get('batch_number'),
|
||||
remarks=item_data.get('remarks'),
|
||||
printing_job=item_data.get('printing_job'),
|
||||
)
|
||||
for item_data in sales_items
|
||||
]
|
||||
@@ -455,7 +457,7 @@ def create_payment_order(
|
||||
创建付款单(资金流出)。
|
||||
"""
|
||||
normalized_date = _normalize_order_date(payment_date)
|
||||
normalized_amount = _ensure_positive_amount(amount, 'amount')
|
||||
normalized_amount = _ensure_non_zero_amount(amount, 'amount')
|
||||
normalized_discount = _ensure_non_negative_amount(discount_amount, 'discount_amount')
|
||||
|
||||
if bank_account and bank_account.merchant_id != merchant.id:
|
||||
@@ -493,7 +495,7 @@ def create_receipt_order(
|
||||
创建收款单(资金流入)。
|
||||
"""
|
||||
normalized_date = _normalize_order_date(receipt_date)
|
||||
normalized_amount = _ensure_positive_amount(amount, 'amount')
|
||||
normalized_amount = _ensure_non_zero_amount(amount, 'amount')
|
||||
normalized_discount = _ensure_non_negative_amount(discount_amount, 'discount_amount')
|
||||
|
||||
if bank_account and bank_account.merchant_id != merchant.id:
|
||||
@@ -740,6 +742,7 @@ def _normalize_order_items(
|
||||
warehouse: basic_info_models.WareHouse,
|
||||
items: List[Dict[str, Any]],
|
||||
is_outgoing: bool,
|
||||
customer: Optional[basic_info_models.Customer] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
||||
"""
|
||||
根据仓库模式校验订单明细,并返回:
|
||||
@@ -768,6 +771,25 @@ def _normalize_order_items(
|
||||
remarks = raw_item.get('remarks')
|
||||
spec = raw_item.get('spec')
|
||||
unit = raw_item.get('unit') or product.get_unit_display() or '米'
|
||||
printing_job = None
|
||||
raw_printing_job_id = raw_item.get('printing_job') or raw_item.get('printing_job_id')
|
||||
if raw_printing_job_id is not None:
|
||||
try:
|
||||
printing_job_id = int(raw_printing_job_id)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError(f'items[{index}].printing_job 必须为数字')
|
||||
from printing import models as printing_models
|
||||
try:
|
||||
printing_job = printing_models.PrintingJob.objects.select_related(
|
||||
'product', 'printing_order__customer'
|
||||
).get(id=printing_job_id)
|
||||
except printing_models.PrintingJob.DoesNotExist as exc:
|
||||
raise ValueError(f'items[{index}].printing_job 不存在或已删除') from exc
|
||||
|
||||
if printing_job.product_id != product.id:
|
||||
raise ValueError(f'items[{index}].printing_job 对应的产品与当前明细不一致')
|
||||
if customer and printing_job.printing_order and printing_job.printing_order.customer_id != customer.id:
|
||||
raise ValueError(f'items[{index}].printing_job 客户不匹配')
|
||||
|
||||
quantity = 0
|
||||
num_of_rolls = 0
|
||||
@@ -844,6 +866,7 @@ def _normalize_order_items(
|
||||
'remarks': remarks,
|
||||
'spec': spec,
|
||||
'consume_detail_ids': consume_detail_ids_str,
|
||||
'printing_job': printing_job,
|
||||
})
|
||||
|
||||
return normalized_items, stock_flow_items
|
||||
@@ -1409,10 +1432,10 @@ def _to_decimal(value, field_name: str) -> Decimal:
|
||||
raise ValueError(f'{field_name} 必须是合法数值') from exc
|
||||
|
||||
|
||||
def _ensure_positive_amount(value, field_name: str) -> Decimal:
|
||||
def _ensure_non_zero_amount(value, field_name: str) -> Decimal:
|
||||
amount = _to_decimal(value, field_name)
|
||||
if amount <= 0:
|
||||
raise ValueError(f'{field_name} 必须大于 0')
|
||||
if amount == 0:
|
||||
raise ValueError(f'{field_name} 不能为 0')
|
||||
return amount
|
||||
|
||||
|
||||
|
||||
@@ -147,6 +147,7 @@ class PurchaseOrderServiceTestCase(TestCase):
|
||||
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_review_purchase_order_approval_triggers_task(self):
|
||||
@@ -210,7 +211,9 @@ class PurchaseOrderServiceTestCase(TestCase):
|
||||
items=self.relaxed_items,
|
||||
created_by=self.user,
|
||||
)
|
||||
self.assertIsNone(purchase_order.items.first().quantity_of_rolls)
|
||||
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,
|
||||
@@ -343,7 +346,9 @@ class SalesOrderServiceTestCase(TestCase):
|
||||
self.assertIsInstance(sales_order, business_models.SalesOrder)
|
||||
self.assertEqual(sales_order.status, business_models.SalesOrderStatusEnum.PENDING)
|
||||
self.assertEqual(sales_order.items.count(), 1)
|
||||
self.assertEqual(sales_order.items.first().quantity_of_rolls, '6,4')
|
||||
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_review_sales_order_triggers_task(self):
|
||||
@@ -501,6 +506,9 @@ class PurchaseReturnServiceTestCase(TestCase):
|
||||
)
|
||||
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_review_purchase_return_order_triggers_task_and_balance(self):
|
||||
order = services.create_purchase_return_order(
|
||||
@@ -601,6 +609,8 @@ class SalesReturnServiceTestCase(TestCase):
|
||||
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_review_sales_return_order_updates_balance(self):
|
||||
order = services.create_sales_return_order(
|
||||
@@ -612,6 +622,7 @@ class SalesReturnServiceTestCase(TestCase):
|
||||
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,
|
||||
@@ -782,15 +793,15 @@ class PaymentReceiptServiceTestCase(TestCase):
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_payment_amount_must_be_positive(self):
|
||||
with self.assertRaises(ValueError):
|
||||
services.create_payment_order(
|
||||
merchant=self.merchant,
|
||||
supplier=self.supplier,
|
||||
payment_date=timezone.now().date(),
|
||||
amount='-1',
|
||||
operator=self.operator,
|
||||
)
|
||||
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(
|
||||
@@ -814,6 +825,16 @@ class PaymentReceiptServiceTestCase(TestCase):
|
||||
)
|
||||
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='无关商户',
|
||||
|
||||
Reference in New Issue
Block a user