1
0
forked from erp-dev/erp
Files
erpnew/business/pre_order_services.py
2026-07-01 11:51:13 +08:00

680 lines
24 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
from decimal import Decimal, InvalidOperation
from typing import Any, Dict, List, Optional
from django.conf import settings
from django.db import transaction
from django.core.exceptions import ValidationError
from django.utils import timezone
from basic_info import models as basic_info_models
from stock import models as stock_models
from . import models
from . import services as business_services
from .notifications import enqueue_business_order_speech
def render_pre_sales_order_created_markdown(
*,
pre_sales_order_id: str,
human_id: str,
created_at: str,
sender_label: str,
customer_name: str,
warehouse_name: str,
kind: str,
items_count: int,
) -> str:
followup_url_template = getattr(
settings,
"PRE_SALES_ORDER_CREATED_FOLLOWUP_URL_TEMPLATE",
"",
)
followup_url = ""
if followup_url_template:
try:
followup_url = str(followup_url_template).format(
pre_sales_order_id=str(pre_sales_order_id or "")
)
except Exception:
followup_url = ""
followup_line = (
f"- **跟进**[点击跟进]({followup_url})\n"
f" {followup_url}\n"
if followup_url
else ""
)
template = getattr(
settings,
"PRE_SALES_ORDER_CREATED_WECOM_MARKDOWN_TEMPLATE",
(
"### 预销售单创建\n"
"\n"
"- **预销售单ID**`{pre_sales_order_id}`\n"
"- **订单编号**`{human_id}`\n"
"- **创建时间**`{created_at}`\n"
"- **发送者**{sender_label}\n"
"- **客户**{customer_name}\n"
"- **仓库**{warehouse_name}\n"
"- **类型**{kind}\n"
"- **明细条数**`{items_count}`\n"
"{followup_line}"
),
)
created_at_str = (created_at or "").strip()
if not created_at_str:
created_at_str = timezone.localtime(timezone.now()).strftime("%Y-%m-%d %H:%M:%S")
return template.format(
pre_sales_order_id=str(pre_sales_order_id or "-") or "-",
human_id=str(human_id or "-") or "-",
created_at=str(created_at_str),
sender_label=str(sender_label or "系统自动发送"),
customer_name=str(customer_name or "-"),
warehouse_name=str(warehouse_name or "-"),
kind=str(kind or "-"),
items_count=int(items_count) if items_count is not None else 0,
followup_line=str(followup_line),
)
def _to_decimal(value: Any, *, field_name: str) -> Decimal:
if value is None or value == '':
raise ValueError(f'{field_name} 不能为空')
try:
return Decimal(str(value))
except (InvalidOperation, ValueError) as exc:
raise ValueError(f'{field_name} 格式不正确') from exc
def _to_optional_int(value: Any) -> Optional[int]:
if value is None or value == '':
return None
try:
parsed = int(value)
except (TypeError, ValueError) as exc:
raise ValueError('order_quantity 必须为整数') from exc
if parsed < 0:
raise ValueError('order_quantity 不能为负数')
return parsed
def _to_optional_positive_int(value: Any, *, field_name: str) -> Optional[int]:
if value is None or value == '':
return None
try:
parsed = int(value)
except (TypeError, ValueError) as exc:
raise ValueError(f'{field_name} 必须为整数') from exc
if parsed <= 0:
raise ValueError(f'{field_name} 必须大于 0')
return parsed
def _normalize_pre_sales_items(
*,
merchant: basic_info_models.Merchant,
items: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
if not isinstance(items, list) or not items:
raise ValueError('items 需要为非空数组')
product_ids: List[int] = []
for item in items:
if not isinstance(item, dict):
raise ValueError('items 每一项需要为对象')
product_id = item.get('product_id') or item.get('product')
if not product_id:
raise ValueError('明细缺少 product_id')
try:
product_ids.append(int(product_id))
except (TypeError, ValueError) as exc:
raise ValueError('product_id 必须为整数') from exc
products_by_id = basic_info_models.Product.objects.filter(
merchant=merchant,
id__in=product_ids,
).in_bulk(field_name='id')
normalized: List[Dict[str, Any]] = []
for item in items:
product_id = int(item.get('product_id') or item.get('product'))
product = products_by_id.get(product_id)
if not product:
raise ValueError(f'产品 {product_id} 不存在')
quantity = _to_decimal(item.get('quantity'), field_name='quantity')
unit = (item.get('unit') or '').strip()
if not unit:
unit = str(product.unit)
if not unit:
raise ValueError('unit 不能为空')
normalized.append(
{
'product_id': product_id,
'product_name': (item.get('product_name') or product.name or '').strip(),
'color': item.get('color'),
'quantity': quantity,
'unit': unit,
'spec': item.get('spec'),
'quantity_of_rolls': item.get('quantity_of_rolls'),
'num_of_rolls': _to_optional_positive_int(item.get('num_of_rolls'), field_name='num_of_rolls'),
'order_quantity': _to_optional_int(item.get('order_quantity')),
'remarks': item.get('remarks'),
}
)
return normalized
def _normalize_pre_purchase_items(
*,
merchant: basic_info_models.Merchant,
items: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
# 与预销售单明细字段保持一致的弱关联产品输入结构
return _normalize_pre_sales_items(merchant=merchant, items=items)
def list_pre_sales_orders(*, merchant: basic_info_models.Merchant):
return models.PreSalesOrder.objects.filter(merchant=merchant).select_related(
'merchant', 'customer', 'warehouse', 'created_by'
).prefetch_related('items')
def get_pre_sales_order(*, merchant: basic_info_models.Merchant, pre_sales_order_id: int) -> models.PreSalesOrder:
try:
return models.PreSalesOrder.objects.select_related(
'merchant', 'customer', 'warehouse', 'created_by'
).prefetch_related('items').get(id=pre_sales_order_id, merchant=merchant)
except models.PreSalesOrder.DoesNotExist as exc:
raise ValueError('预销售单不存在') from exc
def create_pre_sales_order(
*,
merchant: basic_info_models.Merchant,
customer_id: int | str | None,
warehouse_id: int | str | None,
operator: basic_info_models.Employee,
created_by=None,
kind: int | str | None = None,
items: List[Dict[str, Any]],
remarks: str | None = '',
) -> models.PreSalesOrder:
if not customer_id:
raise ValueError('缺少客户 ID')
if not warehouse_id:
raise ValueError('缺少仓库 ID')
try:
customer_id_int = int(customer_id)
except (TypeError, ValueError) as exc:
raise ValueError('customer 必须为整数') from exc
try:
warehouse_id_int = int(warehouse_id)
except (TypeError, ValueError) as exc:
raise ValueError('warehouse 必须为整数') from exc
kind_int: int | None
if kind is None or kind == '':
kind_int = None
else:
try:
kind_int = int(kind)
except (TypeError, ValueError) as exc:
raise ValueError('kind 必须为整数') from exc
try:
customer = basic_info_models.Customer.objects.get(id=customer_id_int, merchant=merchant)
except basic_info_models.Customer.DoesNotExist as exc:
raise ValueError(f'客户 {customer_id_int} 不存在') from exc
try:
warehouse = basic_info_models.WareHouse.objects.get(id=warehouse_id_int, merchant=merchant)
except basic_info_models.WareHouse.DoesNotExist as exc:
raise ValueError(f'仓库 {warehouse_id_int} 不存在') from exc
if operator.merchant_id != merchant.id:
raise ValueError('经办人所属商户与预销售单所属商户不一致')
normalized_items = _normalize_pre_sales_items(merchant=merchant, items=items)
with transaction.atomic():
pre_sales_order = models.PreSalesOrder.objects.create(
merchant=merchant,
customer=customer,
warehouse=warehouse,
created_by=created_by,
operator=operator,
kind=kind_int or models.SalesOrderKindEnum.WHOLESALE,
remarks=remarks,
)
# enforce cross-merchant consistency
try:
pre_sales_order.full_clean()
except ValidationError as exc:
raise ValueError(str(exc)) from exc
models.PreSalesOrderItem.objects.bulk_create(
[
models.PreSalesOrderItem(
pre_sales_order=pre_sales_order,
product_id=item['product_id'],
product_name=item.get('product_name') or '',
color=item.get('color'),
quantity=item.get('quantity'),
unit=item.get('unit') or '',
spec=item.get('spec'),
quantity_of_rolls=item.get('quantity_of_rolls'),
num_of_rolls=item.get('num_of_rolls') or 1,
order_quantity=item.get('order_quantity'),
remarks=item.get('remarks'),
)
for item in normalized_items
]
)
# domain event: pre sales order created
# must be fired only after commit to avoid triggering on rollbacks
def _send_created_signal():
try:
from .signals import pre_sales_order_created
pre_sales_order_created.send(
sender=models.PreSalesOrder,
instance=pre_sales_order,
created_by=created_by,
operator=operator,
items_count=len(normalized_items),
)
except Exception:
import logging
logging.getLogger(__name__).exception(
'[business.pre_order_services] 触发 pre_sales_order_created signal 失败(已忽略)'
)
transaction.on_commit(_send_created_signal)
pre_sales_order.refresh_from_db()
enqueue_business_order_speech(order=pre_sales_order, order_label='预销售单', action_label='创建')
return pre_sales_order
def update_pre_sales_order(
*,
pre_sales_order: models.PreSalesOrder,
customer_id: int | str | None = None,
warehouse_id: int | str | None = None,
kind: int | str | None = None,
items: List[Dict[str, Any]] | None = None,
remarks: str | None = None,
) -> models.PreSalesOrder:
merchant = pre_sales_order.merchant
new_customer = pre_sales_order.customer
if customer_id is not None:
if customer_id == '':
raise ValueError('customer 必须为整数')
try:
customer_id_int = int(customer_id)
except (TypeError, ValueError) as exc:
raise ValueError('customer 必须为整数') from exc
try:
new_customer = basic_info_models.Customer.objects.get(id=customer_id_int, merchant=merchant)
except basic_info_models.Customer.DoesNotExist as exc:
raise ValueError(f'客户 {customer_id_int} 不存在') from exc
new_warehouse = pre_sales_order.warehouse
if warehouse_id is not None:
if warehouse_id == '':
raise ValueError('warehouse 必须为整数')
try:
warehouse_id_int = int(warehouse_id)
except (TypeError, ValueError) as exc:
raise ValueError('warehouse 必须为整数') from exc
try:
new_warehouse = basic_info_models.WareHouse.objects.get(id=warehouse_id_int, merchant=merchant)
except basic_info_models.WareHouse.DoesNotExist as exc:
raise ValueError(f'仓库 {warehouse_id_int} 不存在') from exc
if kind is None or kind == '':
new_kind = pre_sales_order.kind
else:
try:
new_kind = int(kind)
except (TypeError, ValueError) as exc:
raise ValueError('kind 必须为整数') from exc
new_remarks = remarks if remarks is not None else pre_sales_order.remarks
if items is None:
raise ValueError('items 需要为非空数组')
normalized_items = _normalize_pre_sales_items(merchant=merchant, items=items)
with transaction.atomic():
pre_sales_order.customer = new_customer
pre_sales_order.warehouse = new_warehouse
pre_sales_order.kind = new_kind
pre_sales_order.remarks = new_remarks
try:
pre_sales_order.full_clean()
except ValidationError as exc:
raise ValueError(str(exc)) from exc
pre_sales_order.save(update_fields=['customer', 'warehouse', 'kind', 'remarks', 'updated_at'])
pre_sales_order.items.all().delete()
models.PreSalesOrderItem.objects.bulk_create(
[
models.PreSalesOrderItem(
pre_sales_order=pre_sales_order,
product_id=item['product_id'],
product_name=item.get('product_name') or '',
color=item.get('color'),
quantity=item.get('quantity'),
unit=item.get('unit') or '',
spec=item.get('spec'),
quantity_of_rolls=item.get('quantity_of_rolls'),
num_of_rolls=item.get('num_of_rolls') or 1,
order_quantity=item.get('order_quantity'),
remarks=item.get('remarks'),
)
for item in normalized_items
]
)
pre_sales_order.refresh_from_db()
return pre_sales_order
def delete_pre_sales_order(*, pre_sales_order: models.PreSalesOrder) -> None:
with transaction.atomic():
pre_sales_order.delete()
def convert_pre_sales_order_to_sales_order(
*,
merchant: basic_info_models.Merchant,
pre_sales_order_id: int,
operator: basic_info_models.Employee,
created_by=None,
order_date=None,
) -> models.SalesOrder:
order = models.PreSalesOrder.objects.select_related(
'customer',
'warehouse',
'operator',
'created_by',
).prefetch_related('items', 'items__allocation_records').get(
id=pre_sales_order_id,
merchant=merchant,
)
warehouse = order.warehouse
if warehouse.mode != basic_info_models.WareHouseModeEnum.RESTRICT_IN_OUT:
raise PermissionError('仅支持严进严出仓库模式')
items_payload: List[Dict[str, Any]] = []
for item in order.items.all():
if not item.product_id:
raise ValueError(f'明细 {item.id} 缺少 product_id')
if item.quantity is None or str(item.quantity) == '':
raise ValueError(f'明细 {item.id} 缺少 quantity')
consume_ids: List[int] = []
for record in item.allocation_records.all():
if record.status != models.AllocationRecordStatusEnum.ACTIVE:
continue
consume_ids.extend(record.stock_id_list)
if not consume_ids:
raise ValueError(f'明细 {item.id} 缺少配货库存明细')
quantity_from_details: Decimal | None = None
if consume_ids:
details_by_id = stock_models.StockChangeDetail.objects.filter(
id__in=consume_ids,
merchant=merchant,
).in_bulk()
if details_by_id:
total = Decimal('0')
for detail in details_by_id.values():
total += detail.quantity
quantity_from_details = total
items_payload.append(
{
'product_id': item.product_id,
'price': '0',
'quantity': str(quantity_from_details or item.quantity),
'unit': item.unit or None,
'color': item.color,
'spec': item.spec,
'remarks': item.remarks,
'order_quantity': item.order_quantity,
'consume_detail_ids': consume_ids,
}
)
resolved_order_date = order_date or (order.created_at.date() if order.created_at else timezone.localdate())
return business_services.create_sales_order(
merchant=merchant,
customer=order.customer,
order_date=resolved_order_date,
warehouse=warehouse,
operator=operator,
items=items_payload,
remarks=order.remarks or '',
created_by=created_by,
from_pre_sales_order_id=order.id,
)
def list_pre_purchase_orders(*, merchant: basic_info_models.Merchant):
return models.PrePurchaseOrder.objects.filter(merchant=merchant).select_related(
'merchant', 'supplier', 'warehouse', 'created_by'
).prefetch_related('items')
def get_pre_purchase_order(*, merchant: basic_info_models.Merchant, pre_purchase_order_id: int) -> models.PrePurchaseOrder:
try:
return models.PrePurchaseOrder.objects.select_related(
'merchant', 'supplier', 'warehouse', 'created_by'
).prefetch_related('items').get(id=pre_purchase_order_id, merchant=merchant)
except models.PrePurchaseOrder.DoesNotExist as exc:
raise ValueError('预采购单不存在') from exc
def create_pre_purchase_order(
*,
merchant: basic_info_models.Merchant,
supplier_id: int | str | None,
warehouse_id: int | str | None,
operator: basic_info_models.Employee,
created_by=None,
kind: int | str | None = None,
items: List[Dict[str, Any]],
remarks: str | None = '',
) -> models.PrePurchaseOrder:
if not supplier_id:
raise ValueError('缺少供应商 ID')
if not warehouse_id:
raise ValueError('缺少仓库 ID')
try:
supplier_id_int = int(supplier_id)
except (TypeError, ValueError) as exc:
raise ValueError('supplier 必须为整数') from exc
try:
warehouse_id_int = int(warehouse_id)
except (TypeError, ValueError) as exc:
raise ValueError('warehouse 必须为整数') from exc
kind_int: int | None
if kind is None or kind == '':
kind_int = None
else:
try:
kind_int = int(kind)
except (TypeError, ValueError) as exc:
raise ValueError('kind 必须为整数') from exc
try:
supplier = basic_info_models.Supplier.objects.get(id=supplier_id_int, merchant=merchant)
except basic_info_models.Supplier.DoesNotExist as exc:
raise ValueError(f'供应商 {supplier_id_int} 不存在') from exc
try:
warehouse = basic_info_models.WareHouse.objects.get(id=warehouse_id_int, merchant=merchant)
except basic_info_models.WareHouse.DoesNotExist as exc:
raise ValueError(f'仓库 {warehouse_id_int} 不存在') from exc
if operator.merchant_id != merchant.id:
raise ValueError('经办人所属商户与预采购单所属商户不一致')
normalized_items = _normalize_pre_purchase_items(merchant=merchant, items=items)
with transaction.atomic():
pre_purchase_order = models.PrePurchaseOrder.objects.create(
merchant=merchant,
supplier=supplier,
warehouse=warehouse,
created_by=created_by,
operator=operator,
kind=kind_int or models.PurchaseOrderKindEnum.WHOLESALE,
remarks=remarks,
)
try:
pre_purchase_order.full_clean()
except ValidationError as exc:
raise ValueError(str(exc)) from exc
models.PrePurchaseOrderItem.objects.bulk_create(
[
models.PrePurchaseOrderItem(
pre_purchase_order=pre_purchase_order,
product_id=item['product_id'],
product_name=item.get('product_name') or '',
color=item.get('color'),
quantity=item.get('quantity'),
unit=item.get('unit') or '',
spec=item.get('spec'),
quantity_of_rolls=item.get('quantity_of_rolls'),
num_of_rolls=item.get('num_of_rolls') or 1,
order_quantity=item.get('order_quantity'),
remarks=item.get('remarks'),
)
for item in normalized_items
]
)
pre_purchase_order.refresh_from_db()
enqueue_business_order_speech(order=pre_purchase_order, order_label='预采购单', action_label='创建')
return pre_purchase_order
def update_pre_purchase_order(
*,
pre_purchase_order: models.PrePurchaseOrder,
supplier_id: int | str | None = None,
warehouse_id: int | str | None = None,
kind: int | str | None = None,
items: List[Dict[str, Any]] | None = None,
remarks: str | None = None,
) -> models.PrePurchaseOrder:
merchant = pre_purchase_order.merchant
new_supplier = pre_purchase_order.supplier
if supplier_id is not None:
if supplier_id == '':
raise ValueError('supplier 必须为整数')
try:
supplier_id_int = int(supplier_id)
except (TypeError, ValueError) as exc:
raise ValueError('supplier 必须为整数') from exc
try:
new_supplier = basic_info_models.Supplier.objects.get(id=supplier_id_int, merchant=merchant)
except basic_info_models.Supplier.DoesNotExist as exc:
raise ValueError(f'供应商 {supplier_id_int} 不存在') from exc
new_warehouse = pre_purchase_order.warehouse
if warehouse_id is not None:
if warehouse_id == '':
raise ValueError('warehouse 必须为整数')
try:
warehouse_id_int = int(warehouse_id)
except (TypeError, ValueError) as exc:
raise ValueError('warehouse 必须为整数') from exc
try:
new_warehouse = basic_info_models.WareHouse.objects.get(id=warehouse_id_int, merchant=merchant)
except basic_info_models.WareHouse.DoesNotExist as exc:
raise ValueError(f'仓库 {warehouse_id_int} 不存在') from exc
if kind is None or kind == '':
new_kind = pre_purchase_order.kind
else:
try:
new_kind = int(kind)
except (TypeError, ValueError) as exc:
raise ValueError('kind 必须为整数') from exc
new_remarks = remarks if remarks is not None else pre_purchase_order.remarks
if items is None:
raise ValueError('items 需要为非空数组')
normalized_items = _normalize_pre_purchase_items(merchant=merchant, items=items)
with transaction.atomic():
pre_purchase_order.supplier = new_supplier
pre_purchase_order.warehouse = new_warehouse
pre_purchase_order.kind = new_kind
pre_purchase_order.remarks = new_remarks
try:
pre_purchase_order.full_clean()
except ValidationError as exc:
raise ValueError(str(exc)) from exc
pre_purchase_order.save(update_fields=['supplier', 'warehouse', 'kind', 'remarks', 'updated_at'])
pre_purchase_order.items.all().delete()
models.PrePurchaseOrderItem.objects.bulk_create(
[
models.PrePurchaseOrderItem(
pre_purchase_order=pre_purchase_order,
product_id=item['product_id'],
product_name=item.get('product_name') or '',
color=item.get('color'),
quantity=item.get('quantity'),
unit=item.get('unit') or '',
spec=item.get('spec'),
quantity_of_rolls=item.get('quantity_of_rolls'),
num_of_rolls=item.get('num_of_rolls') or 1,
order_quantity=item.get('order_quantity'),
remarks=item.get('remarks'),
)
for item in normalized_items
]
)
pre_purchase_order.refresh_from_db()
return pre_purchase_order
def delete_pre_purchase_order(*, pre_purchase_order: models.PrePurchaseOrder) -> None:
with transaction.atomic():
pre_purchase_order.delete()