1
0
forked from erp-dev/erp

feat: shipment patch

This commit is contained in:
2026-04-02 18:07:06 +08:00
parent d36d9c1d9c
commit 1be840a8c2
14 changed files with 979 additions and 72 deletions

View File

@@ -0,0 +1,26 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("shipment", "0010_salesitem_merchant_shipment_area_and_more"),
]
operations = [
migrations.AlterField(
model_name="shipment",
name="status",
field=models.IntegerField(
choices=[
(1, "草稿(未发布)"),
(2, "已发布"),
(3, "已取消"),
(4, "已驳回"),
(5, "已审核"),
],
default=1,
verbose_name="状态",
),
),
]

View File

@@ -0,0 +1,35 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("shipment", "0011_alter_shipment_status_choices"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.RemoveField(
model_name="shipment",
name="cancelled_at",
),
migrations.AddField(
model_name="shipment",
name="approved_by",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="approved_shipments",
to=settings.AUTH_USER_MODEL,
verbose_name="审核人",
),
),
migrations.AddField(
model_name="shipment",
name="status_modified_at",
field=models.DateTimeField(blank=True, null=True, verbose_name="状态修改时间"),
),
]

View File

@@ -3,7 +3,6 @@ from typing import TYPE_CHECKING
from django.db import models
from django.contrib.auth import get_user_model
from django.utils import timezone
from flower.common import ModelBase
from basic_info import models as basic_models
@@ -23,9 +22,11 @@ class UnitChoices(models.IntegerChoices):
class ShipmentStatus(models.IntegerChoices):
"""出货单状态"""
PENDING_DELIVERY = 1, '待送货'
DELIVERED = 2, '交付'
DRAFT = 1, '草稿(未发布)'
PUBLISHED = 2, '发布'
CANCELLED = 3, '已取消'
REJECTED = 4, '已驳回'
APPROVED = 5, '已审核'
class ExternalFinishedProduct(ModelBase):
@@ -82,14 +83,14 @@ class Shipment(ModelBase):
"""
status = models.IntegerField(
choices=ShipmentStatus.choices,
default=ShipmentStatus.PENDING_DELIVERY,
default=ShipmentStatus.DRAFT,
verbose_name='状态',
)
cancelled_at = models.DateTimeField(
status_modified_at = models.DateTimeField(
null=True,
blank=True,
verbose_name='取消时间',
verbose_name='状态修改时间',
)
cancelled_by = models.ForeignKey(
@@ -101,6 +102,15 @@ class Shipment(ModelBase):
verbose_name='取消人',
)
approved_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='approved_shipments',
verbose_name='审核人',
)
external_id = models.CharField(
max_length=120,
null=True,
@@ -161,15 +171,18 @@ class Shipment(ModelBase):
def cancel(self, operator: User | None) -> None:
"""
取消出货单(记录取消时间与操作者
取消出货单(委托给 service 处理状态机与时间记录)
Args:
operator: 操作者(通常为 request.user可为空
"""
self.status = ShipmentStatus.CANCELLED
self.cancelled_at = timezone.now()
self.cancelled_by = operator
self.save(update_fields=['status', 'cancelled_at', 'cancelled_by', 'updated_at'])
from shipment.services import modify_status
modify_status(
self,
target_status=ShipmentStatus.CANCELLED,
operator=operator,
)
class SalesItem(ModelBase):

View File

@@ -9,8 +9,9 @@ from typing import List
from django.db import transaction
from django.db.models import Count, Exists, IntegerField, OuterRef, QuerySet, Subquery
from django.db.models.functions import Coalesce
from django.utils import timezone
from shipment.models import ExternalFinishedProduct, SalesItem, Shipment
from shipment.models import ExternalFinishedProduct, SalesItem, Shipment, ShipmentStatus
def get_customers_with_unshipped_sales_items(*, merchant) -> QuerySet:
@@ -48,24 +49,50 @@ def get_customers_with_unshipped_sales_items(*, merchant) -> QuerySet:
def get_sales_items_by_printing_order(
printing_order_id: int,
printing_order_id: int | str,
include_already_has_shipment: bool = False,
merchant=None,
) -> QuerySet[SalesItem]:
"""
通过生产订单ID查询对应的销售品
通过生产订单 ID 或 external_order_id 查询对应的销售品
Args:
printing_order_id: 生产订单ID
printing_order_id: 生产订单内部 ID或 external_order_id
include_already_has_shipment: 是否包含已关联出货单的销售品,默认为 False
merchant: 可选的商户约束;传入后会限定只在该商户下解析生产订单
Returns:
SalesItem 查询集
"""
from printing.models import PrintingJob
from printing.models import PrintingJob, PrintingOrder
# 1. 获取该生产订单下所有 PrintingJob 的 ID
raw_value = str(printing_order_id).strip()
order_queryset = PrintingOrder.objects.all()
if merchant is not None:
order_queryset = order_queryset.filter(merchant=merchant)
resolved_order_ids: list[int] = []
# 优先按内部 ID 解析,保持和当前 API 路径语义一致。
if raw_value.isdigit():
resolved_order_ids = list(
order_queryset.filter(id=int(raw_value)).values_list("id", flat=True)[:1]
)
# 内部 ID 未命中时,再按 external_order_id 查询。
if not resolved_order_ids and raw_value:
resolved_order_ids = list(
order_queryset.filter(external_order_id=raw_value).values_list(
"id", flat=True
)
)
if not resolved_order_ids:
return SalesItem.objects.none().select_related("shipment").order_by("id")
# 1. 获取目标生产订单下所有 PrintingJob 的 ID
job_ids = PrintingJob.objects.filter(
printing_order_id=printing_order_id
printing_order_id__in=resolved_order_ids
).values_list("id", flat=True)
# 2. 查询 SalesItem过滤 printing_job_id 在这些 job_ids 中
@@ -110,6 +137,76 @@ def get_sales_items_by_customer(
return queryset.select_related("shipment").order_by("id")
@transaction.atomic
def modify_status(
shipment: Shipment,
*,
target_status: int,
operator=None,
approved_by=None,
) -> Shipment:
"""
修改出货单状态,并记录状态修改时间。
规则:
- 草稿 -> 只能已发布
- 已发布 -> 已审核 / 已驳回 / 已取消
- 已驳回 -> 已审核 / 已取消
- 已审核 -> 已取消
- 已取消 -> 不可再变
- 重复设置同一状态保持幂等,直接返回
"""
current_status = shipment.status
if current_status == target_status:
return shipment
allowed_transitions = {
ShipmentStatus.DRAFT: {ShipmentStatus.PUBLISHED},
ShipmentStatus.PUBLISHED: {
ShipmentStatus.APPROVED,
ShipmentStatus.REJECTED,
ShipmentStatus.CANCELLED,
},
ShipmentStatus.REJECTED: {
ShipmentStatus.APPROVED,
ShipmentStatus.CANCELLED,
},
ShipmentStatus.APPROVED: {
ShipmentStatus.CANCELLED,
},
ShipmentStatus.CANCELLED: set(),
}
if target_status not in allowed_transitions.get(current_status, set()):
raise ValueError(
f"不允许将出货单状态从 {shipment.get_status_display()} 修改为 "
f"{ShipmentStatus(target_status).label}"
)
if target_status == ShipmentStatus.APPROVED and approved_by is None:
raise ValueError("目标状态为已审核时approved_by 不能为空")
shipment.status = target_status
shipment.status_modified_at = timezone.now()
if target_status == ShipmentStatus.CANCELLED:
shipment.cancelled_by = operator
if target_status == ShipmentStatus.APPROVED:
shipment.approved_by = approved_by
shipment.save(
update_fields=[
"status",
"status_modified_at",
"cancelled_by",
"approved_by",
"updated_at",
]
)
return shipment
@transaction.atomic
def create_shipment(
customer_id: int,
@@ -154,6 +251,8 @@ def create_shipment(
# 验证销售品
if sales_item_ids:
from printing.models import PrintingJob
# 查询销售品
sales_items = SalesItem.objects.filter(id__in=sales_item_ids)
found_ids = set(sales_items.values_list("id", flat=True))
@@ -168,6 +267,40 @@ def create_shipment(
shipped_ids = list(already_shipped.values_list("id", flat=True))
raise ValueError(f"以下销售品已关联到其他出货单: {shipped_ids}")
sales_items_list = list(sales_items)
missing_printing_job_ids = [
item.id for item in sales_items_list if not item.printing_job_id
]
if missing_printing_job_ids:
raise ValueError(
f"以下销售品缺少关联生产任务,无法创建出货单: {missing_printing_job_ids}"
)
printing_job_ids = {item.printing_job_id for item in sales_items_list}
printing_job_map = {
job.id: job.printing_order_id
for job in PrintingJob.objects.filter(id__in=printing_job_ids).only(
"id", "printing_order_id"
)
}
invalid_printing_job_ids = sorted(printing_job_ids - set(printing_job_map.keys()))
if invalid_printing_job_ids:
affected_item_ids = sorted(
item.id
for item in sales_items_list
if item.printing_job_id in invalid_printing_job_ids
)
raise ValueError(
f"以下销售品关联的生产任务不存在,无法创建出货单: {affected_item_ids}"
)
printing_order_ids = {
printing_job_map[item.printing_job_id] for item in sales_items_list
}
if len(printing_order_ids) > 1:
raise ValueError("出货单中的销售品必须来自同一个生产订单")
# 创建出货单
shipment = Shipment.objects.create(
merchant=merchant,