1
0
forked from erp-dev/erp
Files
erpnew/business/services.py

753 lines
26 KiB
Python
Raw 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
import logging
from datetime import date, datetime
from decimal import Decimal, InvalidOperation
from typing import Any, Dict, List, Tuple
from django.contrib.auth import get_user_model
from django.db import transaction
from basic_info import models as basic_info_models
from stock import models as stock_models
from stock.services import StockFlowService
from basic_info.services import MerchantSettingService
from . import models
from .tasks import (
create_purchase_order_stock_entries,
create_sales_order_stock_entries,
)
logger = logging.getLogger(__name__)
def _normalize_order_date(value) -> date:
if isinstance(value, date):
return value
if isinstance(value, datetime):
return value.date()
if isinstance(value, str):
try:
return date.fromisoformat(value)
except ValueError as exc:
raise ValueError('order_date 格式不正确,应为 YYYY-MM-DD') from exc
raise ValueError('order_date 格式不正确')
def create_purchase_order(
*,
merchant: basic_info_models.Merchant,
supplier: basic_info_models.Supplier,
order_date,
warehouse: basic_info_models.WareHouse,
operator: basic_info_models.Employee,
items: List[Dict[str, Any]],
remarks: str | None = '',
created_by=None,
) -> models.PurchaseOrder:
"""
创建采购订单并触发异步创建入库单任务。
Args:
merchant: 采购单所属商户
supplier: 供应商
order_date: 订单日期 (date)
warehouse: 入库仓库实例
operator: 经办人
items: 产品明细,字段会根据仓库模式校验
remarks: 备注
created_by: 创建者用户(可选,用于 stock 记录中的 created_by
"""
if not items:
raise ValueError('items 不能为空')
normalized_date = _normalize_order_date(order_date)
purchase_items, stock_flow_items = _normalize_order_items(
merchant=merchant,
warehouse=warehouse,
items=items,
is_outgoing=False,
)
with transaction.atomic():
purchase_order = models.PurchaseOrder.objects.create(
merchant=merchant,
supplier=supplier,
purchase_date=normalized_date,
operator=operator,
warehouse=warehouse,
remarks=remarks,
)
bulk_objects = [
models.PurchaseOrderItem(
purchase_order=purchase_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'],
batch_number=item_data.get('batch_number'),
remarks=item_data.get('remarks'),
)
for item_data in purchase_items
]
models.PurchaseOrderItem.objects.bulk_create(bulk_objects)
purchase_order.refresh_from_db()
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 create_payment_order(
*,
merchant: basic_info_models.Merchant,
supplier: basic_info_models.Supplier,
payment_date,
amount,
operator: basic_info_models.Employee,
remarks: str | None = '',
) -> models.PaymentOrder:
"""
创建付款单(资金流出)。
"""
normalized_date = _normalize_order_date(payment_date)
normalized_amount = _ensure_positive_amount(amount, 'amount')
with transaction.atomic():
payment_order = models.PaymentOrder.objects.create(
merchant=merchant,
supplier=supplier,
payment_date=normalized_date,
amount=normalized_amount,
operator=operator,
remarks=remarks,
)
payment_order.refresh_from_db()
return payment_order
def create_receipt_order(
*,
merchant: basic_info_models.Merchant,
customer: basic_info_models.Customer,
receipt_date,
amount,
operator: basic_info_models.Employee,
remarks: str | None = '',
) -> models.ReceiptOrder:
"""
创建收款单(资金流入)。
"""
normalized_date = _normalize_order_date(receipt_date)
normalized_amount = _ensure_positive_amount(amount, 'amount')
with transaction.atomic():
receipt_order = models.ReceiptOrder.objects.create(
merchant=merchant,
customer=customer,
receipt_date=normalized_date,
amount=normalized_amount,
operator=operator,
remarks=remarks,
)
receipt_order.refresh_from_db()
return receipt_order
def review_purchase_order(
*,
purchase_order: models.PurchaseOrder | None = None,
purchase_order_id: int | None = None,
target_status: models.PurchaseOrderStatusEnum,
reviewed_by=None,
) -> models.PurchaseOrder:
"""
审批或作废采购单。
当目标状态为 APPROVED 且开启自动入库任务时,将触发入库 Celery 任务;
当目标状态为 CANCELLED 时,会在更新状态前确认未生成任何出入库单。
"""
order = _resolve_purchase_order_instance(purchase_order, purchase_order_id)
if target_status not in {
models.PurchaseOrderStatusEnum.APPROVED,
models.PurchaseOrderStatusEnum.CANCELLED,
}:
raise ValueError('target_status 只能是 APPROVED 或 CANCELLED')
if order.status == target_status:
return order
if target_status == models.PurchaseOrderStatusEnum.APPROVED:
if order.status == models.PurchaseOrderStatusEnum.CANCELLED:
raise ValueError('作废状态的采购单无法再次审批')
return _approve_purchase_order(order, reviewed_by)
return _cancel_purchase_order(order)
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 review_payment_order(
*,
payment_order: models.PaymentOrder | None = None,
payment_order_id: int | None = None,
target_status: models.PaymentOrderStatusEnum,
reviewed_by=None,
) -> models.PaymentOrder:
order = _resolve_payment_order_instance(payment_order, payment_order_id)
if target_status not in {
models.PaymentOrderStatusEnum.APPROVED,
models.PaymentOrderStatusEnum.CANCELLED,
}:
raise ValueError('target_status 只能是 APPROVED 或 CANCELLED')
if order.status == target_status:
return order
with transaction.atomic():
order.status = target_status
order.save(update_fields=['status', 'updated_at'])
order.refresh_from_db(fields=['status', 'updated_at'])
return order
def review_receipt_order(
*,
receipt_order: models.ReceiptOrder | None = None,
receipt_order_id: int | None = None,
target_status: models.ReceiptOrderStatusEnum,
reviewed_by=None,
) -> models.ReceiptOrder:
order = _resolve_receipt_order_instance(receipt_order, receipt_order_id)
if target_status not in {
models.ReceiptOrderStatusEnum.APPROVED,
models.ReceiptOrderStatusEnum.CANCELLED,
}:
raise ValueError('target_status 只能是 APPROVED 或 CANCELLED')
if order.status == target_status:
return order
with transaction.atomic():
order.status = target_status
order.save(update_fields=['status', 'updated_at'])
order.refresh_from_db(fields=['status', 'updated_at'])
return 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]]]:
"""
根据仓库模式校验订单明细,并返回:
- normalized_items: 用于创建订单明细模型
- stock_flow_items: 传递给 StockFlowService 的 items 结构
is_outgoing=True 表示出库(销售等),需要额外校验 consume_detail_ids。
"""
normalized_items: List[Dict[str, Any]] = []
stock_flow_items: List[Dict[str, Any]] = []
warehouse_mode = warehouse.mode
for index, raw_item in enumerate(items):
product_id = raw_item.get('product_id')
if not product_id:
raise ValueError(f'items[{index}].product_id 不能为空')
try:
product = basic_info_models.Product.objects.get(id=product_id, merchant=merchant)
except basic_info_models.Product.DoesNotExist as exc:
raise ValueError(f'产品 {product_id} 不存在或不属于当前商户') from exc
price = _to_decimal(raw_item.get('price', '0'), f'items[{index}].price')
empty_diff_percent = _to_decimal(raw_item.get('empty_diff_percent', '0'), f'items[{index}].empty_diff_percent')
color = raw_item.get('color')
batch_number = raw_item.get('batch_number')
remarks = raw_item.get('remarks')
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')
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')
stock_flow_items.append({
'product_id': product.id,
'value': str(quantity),
'num_of_rolls': num_of_rolls,
})
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 数组')
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,
})
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,
})
normalized_items.append({
'product': product,
'price': price,
'color': color,
'quantity': quantity,
'unit': unit,
'empty_diff_percent': empty_diff_percent,
'quantity_of_rolls': quantity_of_rolls,
'num_of_rolls': num_of_rolls,
'batch_number': batch_number,
'remarks': remarks,
'spec': spec,
'consume_detail_ids': consume_detail_ids_str,
})
return normalized_items, stock_flow_items
def _approve_purchase_order(
purchase_order: models.PurchaseOrder,
reviewed_by,
) -> models.PurchaseOrder:
stock_flow_items = _build_stock_flow_items_from_order(purchase_order)
with transaction.atomic():
purchase_order.status = models.PurchaseOrderStatusEnum.APPROVED
purchase_order.save(update_fields=['status', 'updated_at'])
created_by_id = getattr(reviewed_by, 'id', None)
if _auto_stock_task_enabled(purchase_order.merchant):
logger.info('审批通过采购单 %s,触发入库任务', purchase_order.id)
create_purchase_order_stock_entries.delay(
purchase_order_id=purchase_order.id,
warehouse_id=purchase_order.warehouse_id,
items=stock_flow_items,
created_by_id=created_by_id,
)
purchase_order.refresh_from_db(fields=['status', 'updated_at'])
return purchase_order
def _cancel_purchase_order(purchase_order: models.PurchaseOrder) -> models.PurchaseOrder:
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():
purchase_order.status = models.PurchaseOrderStatusEnum.CANCELLED
purchase_order.save(update_fields=['status', 'updated_at'])
purchase_order.refresh_from_db(fields=['status', 'updated_at'])
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,
warehouse_id: int,
items: List[Dict[str, Any]],
created_by_id: int | None = None,
) -> Dict[str, Any]:
"""
根据采购单生成入库记录。
"""
try:
purchase_order = models.PurchaseOrder.objects.select_related('merchant').get(id=purchase_order_id)
except models.PurchaseOrder.DoesNotExist:
logger.error('PurchaseOrder %s 不存在,无法创建入库单', purchase_order_id)
return {'error': 'purchase_order_not_found', 'purchase_order_id': purchase_order_id}
merchant = purchase_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_in(
warehouse_id=warehouse_id,
source_type=stock_models.StockChangeSourceEnum.PURCHASE,
source_id=purchase_order.id,
items=items,
)
payload = {
'purchase_order_id': purchase_order.id,
'stock_change_record_id': getattr(record, 'id', None),
'created_details_count': created_count,
}
logger.info('采购单 %s 入库任务完成: %s', purchase_order.id, payload)
return payload
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]:
"""
根据销售单生成出库记录。
"""
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 = order.items.all()
if not order_items:
raise ValueError('订单没有任何明细,无法生成库存记录')
if warehouse_mode == basic_info_models.WareHouseModeEnum.UNRESTRICTED:
for item in order_items:
items_payload.append({
'product_id': item.product_id,
'value': str(item.quantity),
'num_of_rolls': item.num_of_rolls,
})
return items_payload
# 严进 / 严进严出模式
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_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_numbers,
})
return items_payload
def _auto_stock_task_enabled(merchant: basic_info_models.Merchant) -> bool:
try:
setting = MerchantSettingService.get_setting(
merchant,
basic_info_models.MerchantSettingKeyEnum.AUTO_CREATE_STOCK_CHANGE_TASKS,
)
except basic_info_models.MerchantSetting.DoesNotExist:
logger.warning('商户 %s 未配置 auto_create_stock_change_tasks默认关闭', merchant.id)
return False
return setting.value is True
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=merchant_id,
source_type=source_type,
source_id=source_id,
).exists()
def _resolve_purchase_order_instance(
purchase_order: models.PurchaseOrder | None,
purchase_order_id: int | None,
) -> models.PurchaseOrder:
if purchase_order is None and purchase_order_id is None:
raise ValueError('必须提供 purchase_order 或 purchase_order_id')
if purchase_order is not None:
purchase_order_id = purchase_order.id
return models.PurchaseOrder.objects.select_related('merchant', 'warehouse').prefetch_related('items').get(
id=purchase_order_id
)
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 _resolve_payment_order_instance(
payment_order: models.PaymentOrder | None,
payment_order_id: int | None,
) -> models.PaymentOrder:
if payment_order is None and payment_order_id is None:
raise ValueError('必须提供 payment_order 或 payment_order_id')
if payment_order is not None:
payment_order_id = payment_order.id
return models.PaymentOrder.objects.select_related('merchant', 'supplier').get(id=payment_order_id)
def _resolve_receipt_order_instance(
receipt_order: models.ReceiptOrder | None,
receipt_order_id: int | None,
) -> models.ReceiptOrder:
if receipt_order is None and receipt_order_id is None:
raise ValueError('必须提供 receipt_order 或 receipt_order_id')
if receipt_order is not None:
receipt_order_id = receipt_order.id
return models.ReceiptOrder.objects.select_related('merchant', 'customer').get(id=receipt_order_id)
def _to_decimal(value, field_name: str) -> Decimal:
try:
return Decimal(str(value))
except (InvalidOperation, TypeError) as exc:
raise ValueError(f'{field_name} 必须是合法数值') from exc
def _ensure_positive_amount(value, field_name: str) -> Decimal:
amount = _to_decimal(value, field_name)
if amount <= 0:
raise ValueError(f'{field_name} 必须大于 0')
return amount
def _to_positive_int(value, field_name: str) -> int:
if value is None:
raise ValueError(f'{field_name} 不能为空')
decimal_value = _to_decimal(value, field_name)
if decimal_value <= 0:
raise ValueError(f'{field_name} 必须大于 0')
if decimal_value != decimal_value.to_integral_value():
raise ValueError(f'{field_name} 必须为整数')
return int(decimal_value)