1
0
forked from erp-dev/erp

feat: shipment_delivery

This commit is contained in:
2026-04-03 16:55:22 +08:00
parent 371cd796e7
commit 76b30cf4f4
16 changed files with 2513 additions and 9 deletions

View File

@@ -1,5 +1,5 @@
from django.contrib import admin
from .models import ExternalFinishedProduct, Shipment, SalesItem
from .models import ExternalFinishedProduct, Shipment, SalesItem, ShipmentDelivery
class SalesItemInline(admin.TabularInline):
@@ -18,10 +18,29 @@ class ExternalFinishedProductInline(admin.TabularInline):
@admin.register(Shipment)
class ShipmentAdmin(admin.ModelAdmin):
list_display = ['id', 'merchant', 'customer', 'shipment_date', 'status', 'external_id', 'items_count', 'created_by', 'created_at']
list_display = [
'id',
'merchant',
'customer',
'shipment_date',
'status',
'delivery',
'external_id',
'items_count',
'created_by',
'created_at',
]
list_filter = ['merchant', 'status', 'shipment_date', 'created_at']
search_fields = ['customer__name', 'remark', 'external_id']
search_fields = [
'customer__name',
'remark',
'external_id',
'address',
'contact_name',
'contact_phone',
]
readonly_fields = ['created_at', 'updated_at', 'created_by']
raw_id_fields = ['delivery']
date_hierarchy = 'shipment_date'
inlines = [SalesItemInline, ExternalFinishedProductInline]
@@ -75,3 +94,77 @@ class ExternalFinishedProductAdmin(admin.ModelAdmin):
if not change:
obj.created_by = request.user
super().save_model(request, obj, form, change)
@admin.register(ShipmentDelivery)
class ShipmentDeliveryAdmin(admin.ModelAdmin):
list_display = [
'id',
'merchant',
'driver_name',
'vehicle_trip',
'contact_phone',
'vehicle_capacity',
'status',
'shipments_count',
'operator',
'cancelled_by',
'created_by',
'started_at',
'delivered_at',
'cancelled_at',
'created_at',
]
list_filter = ['merchant', 'status', 'created_at', 'started_at', 'delivered_at', 'cancelled_at']
search_fields = [
'driver_name',
'vehicle_trip',
'contact_phone',
'vehicle_capacity',
'remark',
'internal_remark',
'operator__name',
'cancelled_by__username',
'created_by__username',
]
readonly_fields = ['created_at', 'updated_at', 'created_by', 'operator', 'cancelled_by', 'cancelled_at', 'shipments_summary']
fields = [
'merchant',
'driver_name',
'vehicle_trip',
'contact_phone',
'vehicle_capacity',
'remark',
'internal_remark',
'status',
'started_at',
'delivered_at',
'cancelled_at',
'operator',
'cancelled_by',
'created_by',
'shipments_summary',
'created_at',
'updated_at',
]
def shipments_count(self, obj):
return obj.shipments.count()
shipments_count.short_description = '出货单数量'
def shipments_summary(self, obj):
ids = list(obj.shipments.order_by('id').values_list('id', flat=True))
if not ids:
return ''
return ', '.join(str(item_id) for item_id in ids)
shipments_summary.short_description = '关联出货单'
def save_model(self, request, obj, form, change):
if not change:
obj.created_by = request.user
employee = getattr(request.user, 'employee', None)
if employee:
obj.operator = employee
super().save_model(request, obj, form, change)

View File

@@ -0,0 +1,72 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("shipment", "0012_replace_cancelled_at_with_status_modified_at_and_add_approved_by"),
]
operations = [
migrations.CreateModel(
name="ShipmentDelivery",
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="更新时间")),
("status", models.IntegerField(choices=[(1, "待送货"), (2, "送货中"), (3, "已送达")], db_index=True, default=1, verbose_name="状态")),
("driver_name", models.CharField(db_index=True, max_length=100, verbose_name="司机名")),
("vehicle_trip", models.CharField(db_index=True, max_length=100, verbose_name="车次")),
("started_at", models.DateTimeField(blank=True, null=True, verbose_name="开始送货时间")),
("delivered_at", models.DateTimeField(blank=True, null=True, verbose_name="送达时间")),
("created_by", models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="created_shipment_deliveries", to=settings.AUTH_USER_MODEL, verbose_name="创建人")),
("merchant", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name="shipment_deliveries", to="basic_info.merchant", verbose_name="所属商户")),
],
options={
"verbose_name": "送货单",
"verbose_name_plural": "送货单",
"db_table": "shipment_delivery",
"ordering": ["-created_at", "-id"],
},
),
migrations.AddField(
model_name="shipment",
name="address",
field=models.CharField(blank=True, default="", max_length=255, verbose_name="地址"),
),
migrations.AddField(
model_name="shipment",
name="contact_name",
field=models.CharField(blank=True, default="", max_length=100, verbose_name="联系人"),
),
migrations.AddField(
model_name="shipment",
name="contact_phone",
field=models.CharField(blank=True, default="", max_length=50, verbose_name="联系电话"),
),
migrations.AddField(
model_name="shipment",
name="delivery",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="shipments",
to="shipment.shipmentdelivery",
verbose_name="送货单",
),
),
migrations.AddField(
model_name="shipmentdelivery",
name="internal_remark",
field=models.CharField(blank=True, default="", max_length=200, verbose_name="内部备注"),
),
migrations.AddField(
model_name="shipmentdelivery",
name="remark",
field=models.CharField(blank=True, default="", max_length=200, verbose_name="备注"),
),
]

View File

@@ -0,0 +1,25 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("basic_info", "0025_customer_uniq_customer_merchant_name"),
("shipment", "0013_shipmentdelivery_shipment_delivery"),
]
operations = [
migrations.AddField(
model_name="shipmentdelivery",
name="operator",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="operated_shipment_deliveries",
to="basic_info.employee",
verbose_name="操作人",
),
),
]

View File

@@ -0,0 +1,50 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("shipment", "0014_shipmentdelivery_operator"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="shipmentdelivery",
name="cancelled_at",
field=models.DateTimeField(blank=True, null=True, verbose_name="取消时间"),
),
migrations.AddField(
model_name="shipmentdelivery",
name="cancelled_by",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="cancelled_shipment_deliveries",
to=settings.AUTH_USER_MODEL,
verbose_name="取消人",
),
),
migrations.AlterField(
model_name="shipmentdelivery",
name="status",
field=models.IntegerField(
choices=[(1, "待送货"), (2, "送货中"), (3, "已送达"), (4, "已取消")],
db_index=True,
default=1,
verbose_name="状态",
),
),
migrations.AlterModelOptions(
name="shipmentdelivery",
options={
"ordering": ["-created_at", "-id"],
"permissions": [("cancel_shipmentdelivery", "Can cancel shipment delivery")],
"verbose_name": "送货单",
"verbose_name_plural": "送货单",
},
),
]

View File

@@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("shipment", "0015_shipmentdelivery_cancel_fields_and_permission"),
]
operations = [
migrations.AddField(
model_name="shipmentdelivery",
name="contact_phone",
field=models.CharField(blank=True, default="", max_length=50, verbose_name="联系电话"),
),
migrations.AddField(
model_name="shipmentdelivery",
name="vehicle_capacity",
field=models.CharField(blank=True, default="", max_length=100, verbose_name="车辆容量"),
),
]

View File

@@ -29,6 +29,14 @@ class ShipmentStatus(models.IntegerChoices):
APPROVED = 5, '已审核'
class ShipmentDeliveryStatus(models.IntegerChoices):
"""送货单状态"""
PENDING = 1, '待送货'
IN_TRANSIT = 2, '送货中'
DELIVERED = 3, '已送达'
CANCELLED = 4, '已取消'
class ExternalFinishedProduct(ModelBase):
"""
外部成品表
@@ -111,6 +119,15 @@ class Shipment(ModelBase):
verbose_name='审核人',
)
delivery = models.ForeignKey(
'ShipmentDelivery',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='shipments',
verbose_name='送货单',
)
external_id = models.CharField(
max_length=120,
null=True,
@@ -138,6 +155,27 @@ class Shipment(ModelBase):
help_text='实际出货日期'
)
address = models.CharField(
max_length=255,
blank=True,
default='',
verbose_name='地址',
)
contact_name = models.CharField(
max_length=100,
blank=True,
default='',
verbose_name='联系人',
)
contact_phone = models.CharField(
max_length=50,
blank=True,
default='',
verbose_name='联系电话',
)
area = models.CharField(
max_length=30,
blank=True,
@@ -302,3 +340,121 @@ class SalesItem(ModelBase):
return basic_models.Customer.objects.get(id=self.customer_id)
except basic_models.Customer.DoesNotExist:
return None
class ShipmentDelivery(ModelBase):
"""
送货单
记录一次具体送货行为,可关联多个出货单。
"""
status = models.IntegerField(
choices=ShipmentDeliveryStatus.choices,
default=ShipmentDeliveryStatus.PENDING,
db_index=True,
verbose_name='状态',
)
merchant = models.ForeignKey(
basic_models.Merchant,
on_delete=models.PROTECT,
related_name='shipment_deliveries',
verbose_name='所属商户',
)
driver_name = models.CharField(
max_length=100,
db_index=True,
verbose_name='司机名',
)
vehicle_trip = models.CharField(
max_length=100,
db_index=True,
verbose_name='车次',
)
contact_phone = models.CharField(
max_length=50,
blank=True,
default='',
verbose_name='联系电话',
)
vehicle_capacity = models.CharField(
max_length=100,
blank=True,
default='',
verbose_name='车辆容量',
)
remark = models.CharField(
max_length=200,
blank=True,
default='',
verbose_name='备注',
)
internal_remark = models.CharField(
max_length=200,
blank=True,
default='',
verbose_name='内部备注',
)
started_at = models.DateTimeField(
null=True,
blank=True,
verbose_name='开始送货时间',
)
delivered_at = models.DateTimeField(
null=True,
blank=True,
verbose_name='送达时间',
)
cancelled_at = models.DateTimeField(
null=True,
blank=True,
verbose_name='取消时间',
)
created_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
related_name='created_shipment_deliveries',
verbose_name='创建人',
)
operator = models.ForeignKey(
basic_models.Employee,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='operated_shipment_deliveries',
verbose_name='操作人',
)
cancelled_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='cancelled_shipment_deliveries',
verbose_name='取消人',
)
class Meta:
db_table = 'shipment_delivery'
verbose_name = '送货单'
verbose_name_plural = '送货单'
ordering = ['-created_at', '-id']
permissions = [
('cancel_shipmentdelivery', 'Can cancel shipment delivery'),
]
def __str__(self) -> str:
return f'送货单 #{self.id} - {self.driver_name} / {self.vehicle_trip}'

View File

@@ -11,7 +11,63 @@ from django.db.models import Count, Exists, IntegerField, OuterRef, QuerySet, Su
from django.db.models.functions import Coalesce
from django.utils import timezone
from shipment.models import ExternalFinishedProduct, SalesItem, Shipment, ShipmentStatus
from shipment.models import (
ExternalFinishedProduct,
SalesItem,
Shipment,
ShipmentDelivery,
ShipmentDeliveryStatus,
ShipmentStatus,
)
def _resolve_user_merchant(user):
emp = getattr(user, "employee", None)
return getattr(emp, "merchant", None) if emp else None
def _resolve_user_employee(user):
return getattr(user, "employee", None)
def _resolve_delivery_shipments(
*,
shipment_ids: list[int],
merchant,
current_delivery: ShipmentDelivery | None = None,
) -> list[Shipment]:
normalized_ids = list(dict.fromkeys(shipment_ids or []))
if not normalized_ids:
return []
shipments = list(
Shipment.objects.filter(id__in=normalized_ids, merchant=merchant)
.select_related("customer", "delivery")
.order_by("id")
)
found_ids = {shipment.id for shipment in shipments}
missing_ids = sorted(set(normalized_ids) - found_ids)
if missing_ids:
raise ValueError(f"以下出货单不存在或不属于当前商户: {missing_ids}")
occupied_ids = sorted(
shipment.id
for shipment in shipments
if shipment.delivery_id is not None
and (current_delivery is None or shipment.delivery_id != current_delivery.id)
)
if occupied_ids:
raise ValueError(f"以下出货单已关联到其他送货单: {occupied_ids}")
not_approved_ids = sorted(
shipment.id
for shipment in shipments
if shipment.status != ShipmentStatus.APPROVED
)
if not_approved_ids:
raise ValueError(f"以下出货单未处于已审核状态,不能绑定到送货单: {not_approved_ids}")
return shipments
def get_customers_with_unshipped_sales_items(*, merchant) -> QuerySet:
@@ -137,6 +193,220 @@ def get_sales_items_by_customer(
return queryset.select_related("shipment").order_by("id")
@transaction.atomic
def create_shipment_delivery(
*,
driver_name: str,
vehicle_trip: str,
contact_phone: str = "",
vehicle_capacity: str = "",
shipment_ids: list[int],
created_by,
remark: str = "",
internal_remark: str = "",
) -> ShipmentDelivery:
merchant = _resolve_user_merchant(created_by)
operator = _resolve_user_employee(created_by)
if not merchant:
raise ValueError("用户未关联商户,无法创建送货单")
driver_name = (driver_name or "").strip()
vehicle_trip = (vehicle_trip or "").strip()
if not driver_name:
raise ValueError("driver_name 不能为空")
if not vehicle_trip:
raise ValueError("vehicle_trip 不能为空")
shipments = _resolve_delivery_shipments(
shipment_ids=shipment_ids,
merchant=merchant,
)
delivery = ShipmentDelivery.objects.create(
merchant=merchant,
driver_name=driver_name,
vehicle_trip=vehicle_trip,
contact_phone=(contact_phone or "").strip(),
vehicle_capacity=(vehicle_capacity or "").strip(),
remark=(remark or "").strip(),
internal_remark=(internal_remark or "").strip(),
created_by=created_by,
operator=operator,
)
if shipments:
Shipment.objects.filter(id__in=[shipment.id for shipment in shipments]).update(
delivery=delivery
)
return delivery
@transaction.atomic
def update_shipment_delivery(
delivery: ShipmentDelivery,
*,
driver_name: str | None = None,
vehicle_trip: str | None = None,
contact_phone: str | None = None,
vehicle_capacity: str | None = None,
shipment_ids: list[int] | None = None,
remark: str | None = None,
internal_remark: str | None = None,
operator=None,
) -> ShipmentDelivery:
if driver_name is not None:
normalized_driver_name = driver_name.strip()
if not normalized_driver_name:
raise ValueError("driver_name 不能为空")
delivery.driver_name = normalized_driver_name
if vehicle_trip is not None:
normalized_vehicle_trip = vehicle_trip.strip()
if not normalized_vehicle_trip:
raise ValueError("vehicle_trip 不能为空")
delivery.vehicle_trip = normalized_vehicle_trip
if contact_phone is not None:
delivery.contact_phone = (contact_phone or "").strip()
if vehicle_capacity is not None:
delivery.vehicle_capacity = (vehicle_capacity or "").strip()
if remark is not None:
delivery.remark = (remark or "").strip()
if internal_remark is not None:
delivery.internal_remark = (internal_remark or "").strip()
if shipment_ids is not None:
shipments = _resolve_delivery_shipments(
shipment_ids=shipment_ids,
merchant=delivery.merchant,
current_delivery=delivery,
)
Shipment.objects.filter(delivery=delivery).exclude(
id__in=[shipment.id for shipment in shipments]
).update(delivery=None)
if shipments:
Shipment.objects.filter(id__in=[shipment.id for shipment in shipments]).update(
delivery=delivery
)
else:
Shipment.objects.filter(delivery=delivery).update(delivery=None)
if operator is not None:
delivery.operator = _resolve_user_employee(operator)
delivery.save()
return delivery
@transaction.atomic
def bind_shipments_to_delivery(
delivery: ShipmentDelivery,
*,
shipment_ids: list[int],
operator=None,
) -> ShipmentDelivery:
shipments = _resolve_delivery_shipments(
shipment_ids=shipment_ids,
merchant=delivery.merchant,
current_delivery=delivery,
)
if not shipments:
return delivery
Shipment.objects.filter(id__in=[shipment.id for shipment in shipments]).update(
delivery=delivery
)
if operator is not None:
delivery.operator = _resolve_user_employee(operator)
delivery.save(update_fields=["operator", "updated_at"])
return delivery
@transaction.atomic
def modify_shipment_delivery_status(
delivery: ShipmentDelivery,
*,
target_status: int,
operator=None,
) -> ShipmentDelivery:
try:
target_status = int(target_status)
except (TypeError, ValueError):
raise ValueError("无效的送货单状态")
current_status = delivery.status
if current_status == target_status:
return delivery
allowed_transitions = {
ShipmentDeliveryStatus.PENDING: {ShipmentDeliveryStatus.IN_TRANSIT},
ShipmentDeliveryStatus.IN_TRANSIT: {ShipmentDeliveryStatus.DELIVERED},
ShipmentDeliveryStatus.DELIVERED: set(),
ShipmentDeliveryStatus.CANCELLED: set(),
}
if target_status not in ShipmentDeliveryStatus.values:
raise ValueError("无效的送货单状态")
if target_status == ShipmentDeliveryStatus.CANCELLED:
raise ValueError("取消送货单请使用独立的取消接口")
if target_status not in allowed_transitions.get(current_status, set()):
raise ValueError(
f"不允许将送货单状态从 {delivery.get_status_display()} 修改为 "
f"{ShipmentDeliveryStatus(target_status).label}"
)
now = timezone.now()
delivery.status = target_status
update_fields = ["status", "updated_at"]
if operator is not None:
delivery.operator = _resolve_user_employee(operator)
update_fields.append("operator")
if target_status == ShipmentDeliveryStatus.IN_TRANSIT:
delivery.started_at = now
update_fields.append("started_at")
elif target_status == ShipmentDeliveryStatus.DELIVERED:
delivery.delivered_at = now
update_fields.append("delivered_at")
delivery.save(update_fields=update_fields)
return delivery
@transaction.atomic
def cancel_shipment_delivery(
delivery: ShipmentDelivery,
*,
cancelled_by=None,
operator=None,
) -> ShipmentDelivery:
if delivery.status == ShipmentDeliveryStatus.CANCELLED:
return delivery
delivery.status = ShipmentDeliveryStatus.CANCELLED
delivery.cancelled_at = timezone.now()
delivery.cancelled_by = cancelled_by
if operator is not None:
delivery.operator = _resolve_user_employee(operator)
delivery.save(
update_fields=[
"status",
"cancelled_at",
"cancelled_by",
"operator",
"updated_at",
]
)
return delivery
@transaction.atomic
def delete_shipment_delivery(delivery: ShipmentDelivery) -> None:
Shipment.objects.filter(delivery=delivery).update(delivery=None)
delivery.delete()
@transaction.atomic
def modify_status(
shipment: Shipment,
@@ -213,6 +483,9 @@ def update_shipment(
*,
customer_id: int | None = None,
shipment_date=None,
address: str | None = None,
contact_name: str | None = None,
contact_phone: str | None = None,
area: str | None = None,
remark: str | None = None,
external_id: str | None = None,
@@ -239,6 +512,12 @@ def update_shipment(
if shipment_date is not None:
shipment.shipment_date = shipment_date
if address is not None:
shipment.address = address or ""
if contact_name is not None:
shipment.contact_name = contact_name or ""
if contact_phone is not None:
shipment.contact_phone = contact_phone or ""
if area is not None:
shipment.area = (area or "").strip()
if remark is not None:
@@ -256,6 +535,9 @@ def create_shipment(
shipment_date,
sales_item_ids: List[int],
created_by,
address: str = "",
contact_name: str = "",
contact_phone: str = "",
remark: str = "",
area: str = "",
) -> Shipment:
@@ -349,6 +631,9 @@ def create_shipment(
merchant=merchant,
customer=customer,
shipment_date=shipment_date,
address=address or "",
contact_name=contact_name or "",
contact_phone=contact_phone or "",
area=(area or "").strip(),
remark=remark,
created_by=created_by,
@@ -372,6 +657,9 @@ def create_external_shipment(
external_id: str,
external_finished_products: List[dict],
created_by,
address: str = "",
contact_name: str = "",
contact_phone: str = "",
remark: str = "",
area: str = "",
) -> Shipment:
@@ -409,6 +697,9 @@ def create_external_shipment(
merchant=merchant,
customer=customer,
shipment_date=shipment_date,
address=address or "",
contact_name=contact_name or "",
contact_phone=contact_phone or "",
area=(area or "").strip(),
remark=remark,
created_by=created_by,