1
0
forked from erp-dev/erp

feat: salesitem can change/soft delete

This commit is contained in:
2026-04-06 14:49:02 +08:00
parent d7bc43082e
commit 9afbb73799
16 changed files with 1075 additions and 10 deletions

View File

@@ -1,5 +1,11 @@
from django.contrib import admin
from .models import ExternalFinishedProduct, Shipment, SalesItem, ShipmentDelivery
from .models import (
ExternalFinishedProduct,
SalesItem,
SalesItemChangeRecord,
Shipment,
ShipmentDelivery,
)
class SalesItemInline(admin.TabularInline):
@@ -64,10 +70,10 @@ class ShipmentAdmin(admin.ModelAdmin):
@admin.register(SalesItem)
class SalesItemAdmin(admin.ModelAdmin):
list_display = ['id', 'name', 'quantity', 'unit', 'position', 'shipment_status', 'printing_job_id', 'customer_id', 'created_by', 'created_at']
list_filter = ['unit', 'created_at', ('shipment', admin.EmptyFieldListFilter)]
list_display = ['id', 'name', 'quantity', 'unit', 'position', 'shipment_status', 'printing_job_id', 'customer_id', 'created_by', 'delete_at', 'delete_by', 'created_at']
list_filter = ['unit', 'created_at', 'delete_at', ('shipment', admin.EmptyFieldListFilter)]
search_fields = ['name', 'shipment__customer__name', 'position', 'remark']
readonly_fields = ['created_at', 'updated_at', 'created_by']
readonly_fields = ['created_at', 'updated_at', 'created_by', 'delete_at', 'delete_by']
raw_id_fields = ['shipment']
def shipment_status(self, obj):
@@ -96,6 +102,32 @@ class ExternalFinishedProductAdmin(admin.ModelAdmin):
super().save_model(request, obj, form, change)
@admin.register(SalesItemChangeRecord)
class SalesItemChangeRecordAdmin(admin.ModelAdmin):
list_display = [
"id",
"sales_item",
"operator",
"operated_at",
"created_at",
]
list_filter = ["operated_at", "created_at"]
search_fields = [
"sales_item__name",
"operator__username",
"operator__employee__name",
]
readonly_fields = [
"sales_item",
"operator",
"operated_at",
"before_values",
"after_values",
"created_at",
"updated_at",
]
@admin.register(ShipmentDelivery)
class ShipmentDeliveryAdmin(admin.ModelAdmin):
list_display = [

View File

@@ -0,0 +1,59 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("shipment", "0016_shipmentdelivery_contact_phone_and_vehicle_capacity"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="SalesItemChangeRecord",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_at", models.DateTimeField(auto_now_add=True, verbose_name="创建时间")),
("updated_at", models.DateTimeField(auto_now=True, verbose_name="更新时间")),
("operated_at", models.DateTimeField(verbose_name="操作时间")),
("before_values", models.JSONField(blank=True, default=dict, verbose_name="修改前的值")),
("after_values", models.JSONField(blank=True, default=dict, verbose_name="修改目标值")),
(
"operator",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="sales_item_change_records",
to=settings.AUTH_USER_MODEL,
verbose_name="操作者",
),
),
(
"sales_item",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="change_records",
to="shipment.salesitem",
verbose_name="销售品",
),
),
],
options={
"verbose_name": "销售品修改记录",
"verbose_name_plural": "销售品修改记录",
"db_table": "sales_item_change_record",
"ordering": ["-operated_at", "-id"],
},
),
]

View File

@@ -0,0 +1,40 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("shipment", "0017_salesitemchangerecord"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="salesitem",
name="delete_at",
field=models.DateTimeField(blank=True, null=True, verbose_name="删除时间"),
),
migrations.AddField(
model_name="salesitem",
name="delete_by",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="deleted_sales_items",
to=settings.AUTH_USER_MODEL,
verbose_name="删除人",
),
),
migrations.AlterModelOptions(
name="salesitem",
options={
"ordering": ["id"],
"permissions": [("soft_delete_salesitem", "Can soft delete sales item")],
"verbose_name": "销售品",
"verbose_name_plural": "销售品",
},
),
]

View File

@@ -302,12 +302,30 @@ class SalesItem(ModelBase):
default='',
verbose_name='备注'
)
delete_at = models.DateTimeField(
null=True,
blank=True,
verbose_name='删除时间',
)
delete_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='deleted_sales_items',
verbose_name='删除人',
)
class Meta:
db_table = 'sales_item'
verbose_name = '销售品'
verbose_name_plural = '销售品'
ordering = ['id']
permissions = [
('soft_delete_salesitem', 'Can soft delete sales item'),
]
def __str__(self):
return f'{self.name} x {self.quantity} {self.get_unit_display()}'
@@ -342,6 +360,55 @@ class SalesItem(ModelBase):
return None
class SalesItemChangeRecord(ModelBase):
"""
销售品修改记录。
记录允许修改字段的变更前后值,以及操作者与操作时间。
"""
sales_item = models.ForeignKey(
SalesItem,
on_delete=models.CASCADE,
related_name="change_records",
verbose_name="销售品",
)
operator = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="sales_item_change_records",
verbose_name="操作者",
)
operated_at = models.DateTimeField(
verbose_name="操作时间",
)
before_values = models.JSONField(
default=dict,
blank=True,
verbose_name="修改前的值",
)
after_values = models.JSONField(
default=dict,
blank=True,
verbose_name="修改目标值",
)
class Meta:
db_table = "sales_item_change_record"
verbose_name = "销售品修改记录"
verbose_name_plural = "销售品修改记录"
ordering = ["-operated_at", "-id"]
def __str__(self) -> str:
return f"SalesItemChangeRecord #{self.id} - sales_item={self.sales_item_id}"
class ShipmentDelivery(ModelBase):
"""
送货单

View File

@@ -4,6 +4,7 @@ Shipment 模块业务逻辑层
from __future__ import annotations
from decimal import Decimal, InvalidOperation
from typing import List
from django.db import transaction
@@ -14,6 +15,7 @@ from django.utils import timezone
from shipment.models import (
ExternalFinishedProduct,
SalesItem,
SalesItemChangeRecord,
Shipment,
ShipmentDelivery,
ShipmentDeliveryStatus,
@@ -82,6 +84,7 @@ def get_customers_with_unshipped_sales_items(*, merchant) -> QuerySet:
base_sales_items = SalesItem.objects.filter(
merchant=merchant,
shipment__isnull=True,
delete_at__isnull=True,
customer_id=OuterRef("pk"),
)
count_subquery = (
@@ -153,6 +156,7 @@ def get_sales_items_by_printing_order(
# 2. 查询 SalesItem过滤 printing_job_id 在这些 job_ids 中
queryset = SalesItem.objects.filter(printing_job_id__in=list(job_ids))
queryset = queryset.filter(delete_at__isnull=True)
# 3. 根据参数决定是否过滤已出货的销售品
if not include_already_has_shipment:
@@ -176,6 +180,7 @@ def get_sales_items_by_customer(
queryset = SalesItem.objects.filter(
merchant=merchant,
customer_id=customer_id,
delete_at__isnull=True,
)
if external_order_id:
@@ -193,6 +198,91 @@ def get_sales_items_by_customer(
return queryset.select_related("shipment").order_by("id")
@transaction.atomic
def update_sales_item(
sales_item: SalesItem,
*,
quantity: str | Decimal | None = None,
remark: str | None = None,
position: str | None = None,
operator=None,
) -> SalesItem:
"""
更新销售品的非关系字段。
当前仅允许修改:
- quantity
- remark
- position
"""
changes: dict[str, tuple[object, object]] = {}
if quantity is not None:
try:
normalized_quantity = Decimal(str(quantity))
except (InvalidOperation, ValueError, TypeError) as exc:
raise ValueError(f"数量 {quantity} 格式无效: {exc}")
if normalized_quantity != sales_item.quantity:
changes["quantity"] = (sales_item.quantity, normalized_quantity)
sales_item.quantity = normalized_quantity
if remark is not None:
normalized_remark = remark or ""
if normalized_remark != sales_item.remark:
changes["remark"] = (sales_item.remark, normalized_remark)
sales_item.remark = normalized_remark
if position is not None:
normalized_position = position or ""
if normalized_position != sales_item.position:
changes["position"] = (sales_item.position, normalized_position)
sales_item.position = normalized_position
if not changes:
return sales_item
sales_item.save(update_fields=[*changes.keys(), "updated_at"])
def _serialize_value(value):
if isinstance(value, Decimal):
return str(value)
return value
SalesItemChangeRecord.objects.create(
sales_item=sales_item,
operator=operator,
operated_at=timezone.now(),
before_values={
field: _serialize_value(old_value)
for field, (old_value, _) in changes.items()
},
after_values={
field: _serialize_value(new_value)
for field, (_, new_value) in changes.items()
},
)
return sales_item
@transaction.atomic
def delete_sales_item(
sales_item: SalesItem,
*,
deleted_by=None,
) -> SalesItem:
"""
软删除销售品。
"""
if sales_item.delete_at is not None:
return sales_item
sales_item.delete_at = timezone.now()
sales_item.delete_by = deleted_by
sales_item.save(update_fields=["delete_at", "delete_by", "updated_at"])
return sales_item
@transaction.atomic
def create_shipment_delivery(
*,
@@ -579,7 +669,7 @@ def create_shipment(
from printing.models import PrintingJob
# 查询销售品
sales_items = SalesItem.objects.filter(id__in=sales_item_ids)
sales_items = SalesItem.objects.filter(id__in=sales_item_ids, delete_at__isnull=True)
found_ids = set(sales_items.values_list("id", flat=True))
missing_ids = set(sales_item_ids) - found_ids
@@ -642,7 +732,7 @@ def create_shipment(
# 关联销售品
if sales_item_ids:
updated = SalesItem.objects.filter(
id__in=sales_item_ids, merchant=merchant
id__in=sales_item_ids, merchant=merchant, delete_at__isnull=True
).update(shipment=shipment)
if updated != len(sales_item_ids):
raise ValueError("存在不属于当前商户的销售品,无法关联到出货单")
@@ -752,7 +842,6 @@ def create_sales_item(
Raises:
ValueError: 如果生产任务不存在或不属于当前商户
"""
from decimal import Decimal, InvalidOperation
from printing.models import PrintingJob
# 获取当前用户的商户