forked from erp-dev/erp
feat: sales_order api
This commit is contained in:
@@ -14,7 +14,10 @@ from stock.services import StockFlowService
|
||||
from basic_info.services import MerchantSettingService
|
||||
|
||||
from . import models
|
||||
from .tasks import create_purchase_order_stock_entries
|
||||
from .tasks import (
|
||||
create_purchase_order_stock_entries,
|
||||
create_sales_order_stock_entries,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -61,10 +64,11 @@ def create_purchase_order(
|
||||
raise ValueError('items 不能为空')
|
||||
|
||||
normalized_date = _normalize_order_date(order_date)
|
||||
purchase_items, stock_flow_items = _normalize_purchase_items(
|
||||
purchase_items, stock_flow_items = _normalize_order_items(
|
||||
merchant=merchant,
|
||||
warehouse=warehouse,
|
||||
items=items,
|
||||
is_outgoing=False,
|
||||
)
|
||||
|
||||
with transaction.atomic():
|
||||
@@ -99,6 +103,64 @@ def create_purchase_order(
|
||||
return purchase_order
|
||||
|
||||
|
||||
def create_sales_order(
|
||||
*,
|
||||
merchant: basic_info_models.Merchant,
|
||||
customer: basic_info_models.Customer,
|
||||
order_date,
|
||||
warehouse: basic_info_models.WareHouse,
|
||||
operator: basic_info_models.Employee,
|
||||
items: List[Dict[str, Any]],
|
||||
remarks: str | None = '',
|
||||
created_by=None,
|
||||
) -> models.SalesOrder:
|
||||
"""
|
||||
创建销售订单,后续审批通过后会触发出库任务。
|
||||
"""
|
||||
if not items:
|
||||
raise ValueError('items 不能为空')
|
||||
|
||||
normalized_date = _normalize_order_date(order_date)
|
||||
sales_items, stock_flow_items = _normalize_order_items(
|
||||
merchant=merchant,
|
||||
warehouse=warehouse,
|
||||
items=items,
|
||||
is_outgoing=True,
|
||||
)
|
||||
|
||||
with transaction.atomic():
|
||||
sales_order = models.SalesOrder.objects.create(
|
||||
merchant=merchant,
|
||||
customer=customer,
|
||||
sales_date=normalized_date,
|
||||
operator=operator,
|
||||
warehouse=warehouse,
|
||||
remarks=remarks,
|
||||
)
|
||||
bulk_objects = [
|
||||
models.SalesOrderItem(
|
||||
sales_order=sales_order,
|
||||
product=item_data['product'],
|
||||
price=item_data['price'],
|
||||
color=item_data.get('color'),
|
||||
quantity=item_data['quantity'],
|
||||
unit=item_data['unit'],
|
||||
spec=item_data.get('spec'),
|
||||
empty_diff_percent=item_data['empty_diff_percent'],
|
||||
quantity_of_rolls=item_data.get('quantity_of_rolls'),
|
||||
num_of_rolls=item_data['num_of_rolls'],
|
||||
consume_detail_ids=item_data.get('consume_detail_ids'),
|
||||
batch_number=item_data.get('batch_number'),
|
||||
remarks=item_data.get('remarks'),
|
||||
)
|
||||
for item_data in sales_items
|
||||
]
|
||||
models.SalesOrderItem.objects.bulk_create(bulk_objects)
|
||||
|
||||
sales_order.refresh_from_db()
|
||||
return sales_order
|
||||
|
||||
|
||||
def review_purchase_order(
|
||||
*,
|
||||
purchase_order: models.PurchaseOrder | None = None,
|
||||
@@ -131,18 +193,49 @@ def review_purchase_order(
|
||||
return _cancel_purchase_order(order)
|
||||
|
||||
|
||||
def _normalize_purchase_items(
|
||||
def review_sales_order(
|
||||
*,
|
||||
sales_order: models.SalesOrder | None = None,
|
||||
sales_order_id: int | None = None,
|
||||
target_status: models.SalesOrderStatusEnum,
|
||||
reviewed_by=None,
|
||||
) -> models.SalesOrder:
|
||||
"""
|
||||
审批或作废销售单。
|
||||
"""
|
||||
order = _resolve_sales_order_instance(sales_order, sales_order_id)
|
||||
|
||||
if target_status not in {
|
||||
models.SalesOrderStatusEnum.APPROVED,
|
||||
models.SalesOrderStatusEnum.CANCELLED,
|
||||
}:
|
||||
raise ValueError('target_status 只能是 APPROVED 或 CANCELLED')
|
||||
|
||||
if order.status == target_status:
|
||||
return order
|
||||
|
||||
if target_status == models.SalesOrderStatusEnum.APPROVED:
|
||||
if order.status == models.SalesOrderStatusEnum.CANCELLED:
|
||||
raise ValueError('作废状态的销售单无法再次审批')
|
||||
return _approve_sales_order(order, reviewed_by)
|
||||
|
||||
return _cancel_sales_order(order)
|
||||
|
||||
|
||||
def _normalize_order_items(
|
||||
*,
|
||||
merchant: basic_info_models.Merchant,
|
||||
warehouse: basic_info_models.WareHouse,
|
||||
items: List[Dict[str, Any]],
|
||||
is_outgoing: bool,
|
||||
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
||||
"""
|
||||
根据仓库模式校验采购明细,并返回
|
||||
- purchase_items: 用于创建 PurchaseOrderItem
|
||||
根据仓库模式校验订单明细,并返回:
|
||||
- normalized_items: 用于创建订单明细模型
|
||||
- stock_flow_items: 传递给 StockFlowService 的 items 结构
|
||||
is_outgoing=True 表示出库(销售等),需要额外校验 consume_detail_ids。
|
||||
"""
|
||||
purchase_items: List[Dict[str, Any]] = []
|
||||
normalized_items: List[Dict[str, Any]] = []
|
||||
stock_flow_items: List[Dict[str, Any]] = []
|
||||
|
||||
warehouse_mode = warehouse.mode
|
||||
@@ -164,21 +257,25 @@ def _normalize_purchase_items(
|
||||
spec = raw_item.get('spec')
|
||||
unit = raw_item.get('unit') or product.get_unit_display() or '米'
|
||||
|
||||
quantity = 0
|
||||
num_of_rolls = 0
|
||||
quantity_of_rolls = None
|
||||
consume_detail_ids_str = None
|
||||
|
||||
if warehouse_mode == basic_info_models.WareHouseModeEnum.UNRESTRICTED:
|
||||
if 'numbers' in raw_item and raw_item['numbers']:
|
||||
raise ValueError(f'仓库为宽进模式,items[{index}] 不应提供 numbers')
|
||||
raise ValueError(f'仓库为宽进/宽出模式,items[{index}] 不应提供 numbers')
|
||||
quantity = _to_positive_int(raw_item.get('quantity'), f'items[{index}].quantity')
|
||||
num_of_rolls = _to_positive_int(raw_item.get('num_of_rolls'), f'items[{index}].num_of_rolls')
|
||||
quantity_of_rolls = None
|
||||
stock_flow_items.append({
|
||||
'product_id': product.id,
|
||||
'value': str(quantity),
|
||||
'num_of_rolls': num_of_rolls,
|
||||
})
|
||||
else:
|
||||
elif warehouse_mode == basic_info_models.WareHouseModeEnum.RESTRICT_IN:
|
||||
numbers = raw_item.get('numbers')
|
||||
if not numbers or not isinstance(numbers, list):
|
||||
raise ValueError(f'仓库为严进模式,items[{index}] 需要提供 numbers 数组')
|
||||
raise ValueError(f'仓库为严进宽出模式,items[{index}] 需要提供 numbers 数组')
|
||||
normalized_numbers = [
|
||||
str(_to_positive_int(value, f'items[{index}].numbers[{pos}]'))
|
||||
for pos, value in enumerate(numbers)
|
||||
@@ -190,8 +287,39 @@ def _normalize_purchase_items(
|
||||
'product_id': product.id,
|
||||
'quantities': normalized_numbers,
|
||||
})
|
||||
else: # WareHouseModeEnum.RESTRICT_IN_OUT
|
||||
if is_outgoing:
|
||||
consume_ids = raw_item.get('consume_detail_ids')
|
||||
if not consume_ids or not isinstance(consume_ids, list):
|
||||
raise ValueError(f'仓库为严进严出模式,items[{index}] 需要提供 consume_detail_ids 数组')
|
||||
normalized_ids = [
|
||||
_to_positive_int(value, f'items[{index}].consume_detail_ids[{pos}]')
|
||||
for pos, value in enumerate(consume_ids)
|
||||
]
|
||||
consume_detail_ids_str = ','.join(str(value) for value in normalized_ids)
|
||||
quantity = _to_positive_int(raw_item.get('quantity'), f'items[{index}].quantity')
|
||||
num_of_rolls = len(normalized_ids)
|
||||
stock_flow_items.append({
|
||||
'product_id': product.id,
|
||||
'consume_detail_ids': normalized_ids,
|
||||
})
|
||||
else:
|
||||
numbers = raw_item.get('numbers')
|
||||
if not numbers or not isinstance(numbers, list):
|
||||
raise ValueError(f'仓库为严进严出模式,items[{index}] 需要提供 numbers 数组')
|
||||
normalized_numbers = [
|
||||
str(_to_positive_int(value, f'items[{index}].numbers[{pos}]'))
|
||||
for pos, value in enumerate(numbers)
|
||||
]
|
||||
num_of_rolls = len(normalized_numbers)
|
||||
quantity = sum(int(val) for val in normalized_numbers)
|
||||
quantity_of_rolls = ','.join(normalized_numbers)
|
||||
stock_flow_items.append({
|
||||
'product_id': product.id,
|
||||
'quantities': normalized_numbers,
|
||||
})
|
||||
|
||||
purchase_items.append({
|
||||
normalized_items.append({
|
||||
'product': product,
|
||||
'price': price,
|
||||
'color': color,
|
||||
@@ -203,9 +331,10 @@ def _normalize_purchase_items(
|
||||
'batch_number': batch_number,
|
||||
'remarks': remarks,
|
||||
'spec': spec,
|
||||
'consume_detail_ids': consume_detail_ids_str,
|
||||
})
|
||||
|
||||
return purchase_items, stock_flow_items
|
||||
return normalized_items, stock_flow_items
|
||||
|
||||
|
||||
def _approve_purchase_order(
|
||||
@@ -233,7 +362,11 @@ def _approve_purchase_order(
|
||||
|
||||
|
||||
def _cancel_purchase_order(purchase_order: models.PurchaseOrder) -> models.PurchaseOrder:
|
||||
if _purchase_order_has_stock_records(purchase_order):
|
||||
if _order_has_stock_records(
|
||||
merchant_id=purchase_order.merchant_id,
|
||||
source_type=stock_models.StockChangeSourceEnum.PURCHASE,
|
||||
source_id=purchase_order.id,
|
||||
):
|
||||
raise ValueError('采购单已生成出入库记录,无法作废')
|
||||
|
||||
with transaction.atomic():
|
||||
@@ -244,6 +377,46 @@ def _cancel_purchase_order(purchase_order: models.PurchaseOrder) -> models.Purch
|
||||
return purchase_order
|
||||
|
||||
|
||||
def _approve_sales_order(
|
||||
sales_order: models.SalesOrder,
|
||||
reviewed_by,
|
||||
) -> models.SalesOrder:
|
||||
stock_flow_items = _build_stock_flow_items_from_order(sales_order)
|
||||
|
||||
with transaction.atomic():
|
||||
sales_order.status = models.SalesOrderStatusEnum.APPROVED
|
||||
sales_order.save(update_fields=['status', 'updated_at'])
|
||||
|
||||
created_by_id = getattr(reviewed_by, 'id', None)
|
||||
if _auto_stock_task_enabled(sales_order.merchant):
|
||||
logger.info('审批通过销售单 %s,触发出库任务', sales_order.id)
|
||||
create_sales_order_stock_entries.delay(
|
||||
sales_order_id=sales_order.id,
|
||||
warehouse_id=sales_order.warehouse_id,
|
||||
items=stock_flow_items,
|
||||
created_by_id=created_by_id,
|
||||
)
|
||||
|
||||
sales_order.refresh_from_db(fields=['status', 'updated_at'])
|
||||
return sales_order
|
||||
|
||||
|
||||
def _cancel_sales_order(sales_order: models.SalesOrder) -> models.SalesOrder:
|
||||
if _order_has_stock_records(
|
||||
merchant_id=sales_order.merchant_id,
|
||||
source_type=stock_models.StockChangeSourceEnum.SALES,
|
||||
source_id=sales_order.id,
|
||||
):
|
||||
raise ValueError('销售单已生成出入库记录,无法作废')
|
||||
|
||||
with transaction.atomic():
|
||||
sales_order.status = models.SalesOrderStatusEnum.CANCELLED
|
||||
sales_order.save(update_fields=['status', 'updated_at'])
|
||||
|
||||
sales_order.refresh_from_db(fields=['status', 'updated_at'])
|
||||
return sales_order
|
||||
|
||||
|
||||
def create_purchase_order_stock_entries_sync(
|
||||
*,
|
||||
purchase_order_id: int,
|
||||
@@ -284,16 +457,56 @@ def create_purchase_order_stock_entries_sync(
|
||||
return payload
|
||||
|
||||
|
||||
def _build_stock_flow_items_from_order(purchase_order: models.PurchaseOrder) -> List[Dict[str, Any]]:
|
||||
def create_sales_order_stock_entries_sync(
|
||||
*,
|
||||
sales_order_id: int,
|
||||
warehouse_id: int,
|
||||
items: List[Dict[str, Any]],
|
||||
created_by_id: int | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
根据采购单明细还原 StockFlowService 所需的 items 结构。
|
||||
根据销售单生成出库记录。
|
||||
"""
|
||||
warehouse_mode = purchase_order.warehouse.mode
|
||||
try:
|
||||
sales_order = models.SalesOrder.objects.select_related('merchant').get(id=sales_order_id)
|
||||
except models.SalesOrder.DoesNotExist:
|
||||
logger.error('SalesOrder %s 不存在,无法创建出库单', sales_order_id)
|
||||
return {'error': 'sales_order_not_found', 'sales_order_id': sales_order_id}
|
||||
|
||||
merchant = sales_order.merchant
|
||||
|
||||
created_by = None
|
||||
if created_by_id:
|
||||
UserModel = get_user_model()
|
||||
created_by = UserModel.objects.filter(id=created_by_id).first()
|
||||
|
||||
service = StockFlowService(merchant=merchant, created_by=created_by)
|
||||
record, details, created_count = service.stock_out(
|
||||
warehouse_id=warehouse_id,
|
||||
source_type=stock_models.StockChangeSourceEnum.SALES,
|
||||
source_id=sales_order.id,
|
||||
items=items,
|
||||
)
|
||||
|
||||
payload = {
|
||||
'sales_order_id': sales_order.id,
|
||||
'stock_change_record_id': getattr(record, 'id', None),
|
||||
'created_details_count': created_count,
|
||||
}
|
||||
logger.info('销售单 %s 出库任务完成: %s', sales_order.id, payload)
|
||||
return payload
|
||||
|
||||
|
||||
def _build_stock_flow_items_from_order(order) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
根据订单明细还原 StockFlowService 所需的 items 结构。
|
||||
"""
|
||||
warehouse_mode = order.warehouse.mode
|
||||
items_payload: List[Dict[str, Any]] = []
|
||||
|
||||
order_items = purchase_order.items.all()
|
||||
order_items = order.items.all()
|
||||
if not order_items:
|
||||
raise ValueError('采购单没有任何明细,无法生成入库记录')
|
||||
raise ValueError('订单没有任何明细,无法生成库存记录')
|
||||
|
||||
if warehouse_mode == basic_info_models.WareHouseModeEnum.UNRESTRICTED:
|
||||
for item in order_items:
|
||||
@@ -306,13 +519,24 @@ def _build_stock_flow_items_from_order(purchase_order: models.PurchaseOrder) ->
|
||||
|
||||
# 严进 / 严进严出模式
|
||||
for item in order_items:
|
||||
consume_ids_raw = getattr(item, 'consume_detail_ids', None)
|
||||
if consume_ids_raw:
|
||||
normalized_ids = [value.strip() for value in consume_ids_raw.split(',') if value.strip()]
|
||||
if not normalized_ids:
|
||||
raise ValueError('严进严出订单缺少 consume_detail_ids 数据,无法生成库存记录')
|
||||
items_payload.append({
|
||||
'product_id': item.product_id,
|
||||
'consume_detail_ids': [int(value) for value in normalized_ids],
|
||||
})
|
||||
continue
|
||||
|
||||
raw_numbers = (item.quantity_of_rolls or '').split(',')
|
||||
normalized = [value.strip() for value in raw_numbers if value.strip()]
|
||||
if not normalized:
|
||||
raise ValueError('严进仓采购单缺少 numbers 数据,无法生成入库记录')
|
||||
normalized_numbers = [value.strip() for value in raw_numbers if value.strip()]
|
||||
if not normalized_numbers:
|
||||
raise ValueError('严进仓订单缺少 numbers 数据,无法生成库存记录')
|
||||
items_payload.append({
|
||||
'product_id': item.product_id,
|
||||
'quantities': normalized,
|
||||
'quantities': normalized_numbers,
|
||||
})
|
||||
return items_payload
|
||||
|
||||
@@ -329,11 +553,16 @@ def _auto_stock_task_enabled(merchant: basic_info_models.Merchant) -> bool:
|
||||
return setting.value is True
|
||||
|
||||
|
||||
def _purchase_order_has_stock_records(purchase_order: models.PurchaseOrder) -> bool:
|
||||
def _order_has_stock_records(
|
||||
*,
|
||||
merchant_id: int,
|
||||
source_type: stock_models.StockChangeSourceEnum,
|
||||
source_id: int,
|
||||
) -> bool:
|
||||
return stock_models.StockChangeRecord.objects.filter(
|
||||
merchant_id=purchase_order.merchant_id,
|
||||
source_type=stock_models.StockChangeSourceEnum.PURCHASE,
|
||||
source_id=purchase_order.id,
|
||||
merchant_id=merchant_id,
|
||||
source_type=source_type,
|
||||
source_id=source_id,
|
||||
).exists()
|
||||
|
||||
|
||||
@@ -352,6 +581,21 @@ def _resolve_purchase_order_instance(
|
||||
)
|
||||
|
||||
|
||||
def _resolve_sales_order_instance(
|
||||
sales_order: models.SalesOrder | None,
|
||||
sales_order_id: int | None,
|
||||
) -> models.SalesOrder:
|
||||
if sales_order is None and sales_order_id is None:
|
||||
raise ValueError('必须提供 sales_order 或 sales_order_id')
|
||||
|
||||
if sales_order is not None:
|
||||
sales_order_id = sales_order.id
|
||||
|
||||
return models.SalesOrder.objects.select_related('merchant', 'warehouse').prefetch_related('items').get(
|
||||
id=sales_order_id
|
||||
)
|
||||
|
||||
|
||||
def _to_decimal(value, field_name: str) -> Decimal:
|
||||
try:
|
||||
return Decimal(str(value))
|
||||
|
||||
Reference in New Issue
Block a user