1
0
forked from erp-dev/erp

feat: added n-to-1 relation between sales_order_item and printing_job model

This commit is contained in:
2025-12-11 18:09:10 +08:00
parent 633031d7cb
commit 1a8c446365
30 changed files with 41736 additions and 40 deletions

View File

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

View File

@@ -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='关联印染任务',

View File

@@ -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='关联印染任务'),
),
]

View File

@@ -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='各条数数量'),
),
]

View File

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

View File

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

View File

@@ -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='无关商户',