diff --git a/src/api/inventory/dashboard_routes.py b/src/api/inventory/dashboard_routes.py new file mode 100644 index 0000000..2e564c8 --- /dev/null +++ b/src/api/inventory/dashboard_routes.py @@ -0,0 +1,82 @@ +""" +仪表盘路由模块 + +提供进销存系统的仪表盘统计数据,包括: +- 基础数据统计(产品数、供应商数、客户数、仓库数) +- 库存统计(总库存量、库存总价值) +- 订单统计(待处理采购订单、待处理销售订单) +- 低库存产品预警(库存量低于最小库存的产品列表) + +路由前缀: /api/dashboard +""" +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func + +from database.database import get_db_session +from services.auth_service import get_current_active_user +from models.database import ( + User, Product, Supplier, Customer, Warehouse, + Inventory, PurchaseOrder, SalesOrder +) + +router = APIRouter(prefix="/dashboard", tags=["仪表盘"]) + + +@router.get("") +async def get_dashboard( + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + material_count = await db_session.scalar( + select(func.count(Product.id)).where(Product.is_active == True, Product.item_type == "material") + ) or 0 + finished_product_count = await db_session.scalar( + select(func.count(Product.id)).where(Product.is_active == True, Product.item_type == "finished") + ) or 0 + supplier_count = await db_session.scalar(select(func.count(Supplier.id)).where(Supplier.is_active == True)) + customer_count = await db_session.scalar(select(func.count(Customer.id)).where(Customer.is_active == True)) + warehouse_count = await db_session.scalar(select(func.count(Warehouse.id)).where(Warehouse.is_active == True)) + + total_stock = await db_session.scalar( + select(func.sum(Inventory.quantity)) + .join(Product, Inventory.product_id == Product.id) + .where(Product.item_type == "material") + ) or 0 + total_value = await db_session.scalar( + select(func.sum(Inventory.quantity * Product.cost_price)) + .join(Product, Inventory.product_id == Product.id) + .where(Product.item_type == "material") + ) or 0 + + pending_purchase = await db_session.scalar( + select(func.count(PurchaseOrder.id)).where(PurchaseOrder.status == "pending") + ) + pending_sales = await db_session.scalar( + select(func.count(SalesOrder.id)).where(SalesOrder.status == "pending") + ) + + low_stock_products = await db_session.execute( + select(Product, Inventory) + .join(Inventory, Product.id == Inventory.product_id) + .where(Product.item_type == "material") + .where(Inventory.quantity <= Product.min_stock) + .limit(10) + ) + low_stock = [ + {"id": p.id, "name": p.name, "sku": p.sku, "quantity": i.quantity, "min_stock": p.min_stock} + for p, i in low_stock_products.all() + ] + + return { + "finished_product_count": finished_product_count, + "material_count": material_count, + "supplier_count": supplier_count, + "customer_count": customer_count, + "warehouse_count": warehouse_count, + "total_stock": total_stock, + "total_value": round(total_value, 2), + "pending_purchase": pending_purchase, + "pending_sales": pending_sales, + "low_stock_products": low_stock + } diff --git a/src/api/inventory/finance_routes.py b/src/api/inventory/finance_routes.py new file mode 100644 index 0000000..81e5140 --- /dev/null +++ b/src/api/inventory/finance_routes.py @@ -0,0 +1,751 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func +from sqlalchemy.orm import selectinload +from typing import Optional, List, Dict, Tuple +from datetime import datetime +from decimal import Decimal + +from database.database import get_db_session +from services.auth_service import get_current_active_user +from models.database import ( + User, + Customer, + Supplier, + Product, + SalesOrder, + SalesOrderItem, + PurchaseOrder, + PurchaseOrderItem, + FinanceTransaction, + FinanceAllocation, +) +from .schemas import ( + ReceiptCreate, + PaymentCreate, + FinanceTransactionResponse, + FinanceSummaryResponse, + ReceivableItemResponse, + PayableItemResponse, + FinancePartnerStatementResponse, + PartnerStatementItemResponse, + FinancePartnerProductStatementResponse, + PartnerProductStatementItemResponse, + PaginatedResponse, +) +from .utils import generate_order_no +from utils.logger import get_logger + +logger = get_logger(__name__) + +router = APIRouter(prefix="/finance", tags=["财务管理"]) + + +def _build_transaction_response(txn: FinanceTransaction) -> FinanceTransactionResponse: + allocations = [ + { + "id": item.id, + "order_type": item.order_type, + "order_id": item.order_id, + "allocated_amount": item.allocated_amount, + } + for item in txn.allocations + ] + return FinanceTransactionResponse( + id=txn.id, + txn_no=txn.txn_no, + txn_type=txn.txn_type, + partner_type=txn.partner_type, + partner_id=txn.partner_id, + amount=txn.amount, + txn_date=txn.txn_date, + method=txn.method, + account_name=txn.account_name, + status=txn.status, + remark=txn.remark, + created_at=txn.created_at, + allocations=allocations, + ) + + +def _validate_allocation_total(transaction_amount: float, allocation_amounts: List[float]): + allocated_total = sum(allocation_amounts) + if allocated_total - transaction_amount > 1e-6: + raise HTTPException(status_code=400, detail="核销总额不能大于单据金额") + + +def _resolve_period_scope(year: Optional[int], quarter: Optional[int]) -> Tuple[int, Optional[int], str, datetime, datetime]: + now = datetime.now() + selected_year = year or now.year + if selected_year < 2000 or selected_year > 2100: + raise HTTPException(status_code=400, detail="年份超出支持范围") + + if quarter is not None and quarter not in [1, 2, 3, 4]: + raise HTTPException(status_code=400, detail="季度必须是1-4") + + if quarter is None: + period_start = datetime(selected_year, 1, 1) + period_end = datetime(selected_year + 1, 1, 1) + period_label = f"{selected_year}年" + else: + start_month = (quarter - 1) * 3 + 1 + period_start = datetime(selected_year, start_month, 1) + if quarter == 4: + period_end = datetime(selected_year + 1, 1, 1) + else: + period_end = datetime(selected_year, start_month + 3, 1) + period_label = f"{selected_year}年Q{quarter}" + + return selected_year, quarter, period_label, period_start, period_end + + +@router.post("/receipts", response_model=FinanceTransactionResponse, status_code=201) +async def create_receipt( + payload: ReceiptCreate, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user), +): + customer_result = await db_session.execute( + select(Customer).where(Customer.id == payload.customer_id, Customer.is_active == True) + ) + customer = customer_result.scalar_one_or_none() + if not customer: + raise HTTPException(status_code=404, detail="客户不存在") + + _validate_allocation_total(payload.amount, [item.allocated_amount for item in payload.allocations]) + + txn = FinanceTransaction( + txn_no=generate_order_no("RC"), + txn_type="receipt", + partner_type="customer", + partner_id=payload.customer_id, + amount=payload.amount, + txn_date=payload.txn_date or datetime.now(), + method=payload.method, + account_name=payload.account_name, + status="confirmed", + remark=payload.remark, + operator_id=current_user.id, + ) + db_session.add(txn) + await db_session.flush() + + for allocation in payload.allocations: + if allocation.order_type != "sales": + raise HTTPException(status_code=400, detail="收款单只允许核销销售订单") + + order_result = await db_session.execute( + select(SalesOrder).where(SalesOrder.id == allocation.order_id, SalesOrder.customer_id == payload.customer_id) + ) + sales_order = order_result.scalar_one_or_none() + if not sales_order: + raise HTTPException(status_code=404, detail=f"销售订单不存在: {allocation.order_id}") + + remaining = (sales_order.total_amount or 0) - (sales_order.received_amount or 0) + if allocation.allocated_amount - remaining > 1e-6: + raise HTTPException(status_code=400, detail=f"销售订单核销超额: {sales_order.order_no}") + + db_session.add( + FinanceAllocation( + transaction_id=txn.id, + order_type="sales", + order_id=sales_order.id, + allocated_amount=allocation.allocated_amount, + ) + ) + sales_order.received_amount = (sales_order.received_amount or 0) + allocation.allocated_amount + + await db_session.commit() + result = await db_session.execute( + select(FinanceTransaction) + .options(selectinload(FinanceTransaction.allocations)) + .where(FinanceTransaction.id == txn.id) + ) + created = result.scalar_one() + return _build_transaction_response(created) + + +@router.post("/payments", response_model=FinanceTransactionResponse, status_code=201) +async def create_payment( + payload: PaymentCreate, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user), +): + supplier_result = await db_session.execute( + select(Supplier).where(Supplier.id == payload.supplier_id, Supplier.is_active == True) + ) + supplier = supplier_result.scalar_one_or_none() + if not supplier: + raise HTTPException(status_code=404, detail="供应商不存在") + + _validate_allocation_total(payload.amount, [item.allocated_amount for item in payload.allocations]) + + txn = FinanceTransaction( + txn_no=generate_order_no("PY"), + txn_type="payment", + partner_type="supplier", + partner_id=payload.supplier_id, + amount=payload.amount, + txn_date=payload.txn_date or datetime.now(), + method=payload.method, + account_name=payload.account_name, + status="confirmed", + remark=payload.remark, + operator_id=current_user.id, + ) + db_session.add(txn) + await db_session.flush() + + for allocation in payload.allocations: + if allocation.order_type != "purchase": + raise HTTPException(status_code=400, detail="付款单只允许核销采购订单") + + order_result = await db_session.execute( + select(PurchaseOrder).where(PurchaseOrder.id == allocation.order_id, PurchaseOrder.supplier_id == payload.supplier_id) + ) + purchase_order = order_result.scalar_one_or_none() + if not purchase_order: + raise HTTPException(status_code=404, detail=f"采购订单不存在: {allocation.order_id}") + + remaining = (purchase_order.total_amount or 0) - (purchase_order.paid_amount or 0) + if allocation.allocated_amount - remaining > 1e-6: + raise HTTPException(status_code=400, detail=f"采购订单核销超额: {purchase_order.order_no}") + + db_session.add( + FinanceAllocation( + transaction_id=txn.id, + order_type="purchase", + order_id=purchase_order.id, + allocated_amount=allocation.allocated_amount, + ) + ) + purchase_order.paid_amount = (purchase_order.paid_amount or 0) + allocation.allocated_amount + + await db_session.commit() + result = await db_session.execute( + select(FinanceTransaction) + .options(selectinload(FinanceTransaction.allocations)) + .where(FinanceTransaction.id == txn.id) + ) + created = result.scalar_one() + return _build_transaction_response(created) + + +@router.get("/transactions", response_model=PaginatedResponse[FinanceTransactionResponse]) +async def list_transactions( + txn_type: Optional[str] = None, + status: Optional[str] = "confirmed", + year: Optional[int] = Query(None, ge=2000, le=2100), + quarter: Optional[int] = Query(None, ge=1, le=4), + skip: int = Query(0, ge=0), + limit: int = Query(20, ge=1, le=100), + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user), +): + base_query = ( + select(FinanceTransaction) + .options(selectinload(FinanceTransaction.allocations)) + .order_by(FinanceTransaction.created_at.desc()) + ) + if txn_type: + base_query = base_query.where(FinanceTransaction.txn_type == txn_type) + if status: + base_query = base_query.where(FinanceTransaction.status == status) + if year is not None or quarter is not None: + _, _, _, period_start, period_end = _resolve_period_scope(year, quarter) + base_query = base_query.where(FinanceTransaction.txn_date >= period_start).where(FinanceTransaction.txn_date < period_end) + + count_query = select(func.count()).select_from(base_query.subquery()) + total = await db_session.scalar(count_query) or 0 + + query = base_query.offset(skip).limit(limit) + result = await db_session.execute(query) + rows = result.scalars().all() + return PaginatedResponse( + items=[_build_transaction_response(item) for item in rows], + total=total, skip=skip, limit=limit + ) + + +@router.post("/transactions/{transaction_id}/void") +async def void_transaction( + transaction_id: int, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user), +): + result = await db_session.execute( + select(FinanceTransaction) + .options(selectinload(FinanceTransaction.allocations)) + .where(FinanceTransaction.id == transaction_id) + ) + txn = result.scalar_one_or_none() + if not txn: + raise HTTPException(status_code=404, detail="财务单据不存在") + if txn.status == "voided": + return {"message": "单据已作废"} + + for allocation in txn.allocations: + if allocation.order_type == "sales": + sales_result = await db_session.execute(select(SalesOrder).where(SalesOrder.id == allocation.order_id)) + sales_order = sales_result.scalar_one_or_none() + if sales_order: + sales_order.received_amount = max((sales_order.received_amount or 0) - allocation.allocated_amount, 0) + elif allocation.order_type == "purchase": + purchase_result = await db_session.execute(select(PurchaseOrder).where(PurchaseOrder.id == allocation.order_id)) + purchase_order = purchase_result.scalar_one_or_none() + if purchase_order: + purchase_order.paid_amount = max((purchase_order.paid_amount or 0) - allocation.allocated_amount, 0) + + txn.status = "voided" + await db_session.commit() + logger.warning( + "财务单据已作废: txn_no=%s txn_type=%s amount=%s operator_id=%s", + txn.txn_no, txn.txn_type, txn.amount, current_user.id + ) + return {"message": "单据已作废"} + + +@router.get("/receivables", response_model=List[ReceivableItemResponse]) +async def list_receivables( + year: Optional[int] = Query(None, ge=2000, le=2100), + quarter: Optional[int] = Query(None, ge=1, le=4), + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=200), + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user), +): + query = ( + select(SalesOrder, Customer) + .join(Customer, SalesOrder.customer_id == Customer.id) + .where((SalesOrder.total_amount - SalesOrder.received_amount) > 0) + .order_by(SalesOrder.created_at.desc()) + ) + if year is not None or quarter is not None: + _, _, _, period_start, period_end = _resolve_period_scope(year, quarter) + query = query.where(SalesOrder.order_date >= period_start).where(SalesOrder.order_date < period_end) + result = await db_session.execute(query.offset(skip).limit(limit)) + rows = [] + for order, customer in result.all(): + receivable_amount = (order.total_amount or 0) - (order.received_amount or 0) + rows.append( + ReceivableItemResponse( + order_id=order.id, + order_no=order.order_no, + customer_id=customer.id, + customer_name=customer.name, + order_date=order.order_date, + total_amount=order.total_amount or 0, + received_amount=order.received_amount or 0, + receivable_amount=receivable_amount, + status=order.status, + ) + ) + return rows + + +@router.get("/payables", response_model=List[PayableItemResponse]) +async def list_payables( + year: Optional[int] = Query(None, ge=2000, le=2100), + quarter: Optional[int] = Query(None, ge=1, le=4), + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=200), + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user), +): + query = ( + select(PurchaseOrder, Supplier) + .join(Supplier, PurchaseOrder.supplier_id == Supplier.id) + .where((PurchaseOrder.total_amount - PurchaseOrder.paid_amount) > 0) + .order_by(PurchaseOrder.created_at.desc()) + ) + if year is not None or quarter is not None: + _, _, _, period_start, period_end = _resolve_period_scope(year, quarter) + query = query.where(PurchaseOrder.order_date >= period_start).where(PurchaseOrder.order_date < period_end) + result = await db_session.execute(query.offset(skip).limit(limit)) + rows = [] + for order, supplier in result.all(): + payable_amount = (order.total_amount or 0) - (order.paid_amount or 0) + rows.append( + PayableItemResponse( + order_id=order.id, + order_no=order.order_no, + supplier_id=supplier.id, + supplier_name=supplier.name, + order_date=order.order_date, + total_amount=order.total_amount or 0, + paid_amount=order.paid_amount or 0, + payable_amount=payable_amount, + status=order.status, + ) + ) + return rows + + +@router.get("/summary", response_model=FinanceSummaryResponse) +async def get_finance_summary( + year: Optional[int] = Query(None, ge=2000, le=2100), + quarter: Optional[int] = Query(None, ge=1, le=4), + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user), +): + receivable_total = await db_session.scalar( + select(func.coalesce(func.sum(SalesOrder.total_amount - SalesOrder.received_amount), 0)) + .where((SalesOrder.total_amount - SalesOrder.received_amount) > 0) + ) or 0 + payable_total = await db_session.scalar( + select(func.coalesce(func.sum(PurchaseOrder.total_amount - PurchaseOrder.paid_amount), 0)) + .where((PurchaseOrder.total_amount - PurchaseOrder.paid_amount) > 0) + ) or 0 + + now = datetime.now() + month_start = datetime(now.year, now.month, 1) + + monthly_receipt_total = await db_session.scalar( + select(func.coalesce(func.sum(FinanceTransaction.amount), 0)) + .where(FinanceTransaction.txn_type == "receipt") + .where(FinanceTransaction.status == "confirmed") + .where(FinanceTransaction.txn_date >= month_start) + ) or 0 + monthly_payment_total = await db_session.scalar( + select(func.coalesce(func.sum(FinanceTransaction.amount), 0)) + .where(FinanceTransaction.txn_type == "payment") + .where(FinanceTransaction.status == "confirmed") + .where(FinanceTransaction.txn_date >= month_start) + ) or 0 + + selected_year, selected_quarter, period_label, period_start, period_end = _resolve_period_scope(year, quarter) + period_receipt_total = await db_session.scalar( + select(func.coalesce(func.sum(FinanceTransaction.amount), 0)) + .where(FinanceTransaction.txn_type == "receipt") + .where(FinanceTransaction.status == "confirmed") + .where(FinanceTransaction.txn_date >= period_start) + .where(FinanceTransaction.txn_date < period_end) + ) or 0 + period_payment_total = await db_session.scalar( + select(func.coalesce(func.sum(FinanceTransaction.amount), 0)) + .where(FinanceTransaction.txn_type == "payment") + .where(FinanceTransaction.status == "confirmed") + .where(FinanceTransaction.txn_date >= period_start) + .where(FinanceTransaction.txn_date < period_end) + ) or 0 + + return FinanceSummaryResponse( + receivable_total=Decimal(str(receivable_total)), + payable_total=Decimal(str(payable_total)), + monthly_receipt_total=Decimal(str(monthly_receipt_total)), + monthly_payment_total=Decimal(str(monthly_payment_total)), + selected_year=selected_year, + selected_quarter=selected_quarter, + period_label=period_label, + period_receipt_total=Decimal(str(period_receipt_total)), + period_payment_total=Decimal(str(period_payment_total)), + overdue_receivable_count=0, + overdue_payable_count=0, + ) + + +@router.get("/partner-statement/{partner_type}", response_model=FinancePartnerStatementResponse) +async def get_partner_statement( + partner_type: str, + year: Optional[int] = Query(None, ge=2000, le=2100), + quarter: Optional[int] = Query(None, ge=1, le=4), + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user), +): + if partner_type not in ["customer", "supplier"]: + raise HTTPException(status_code=400, detail="partner_type 必须是 customer 或 supplier") + + selected_year, selected_quarter, period_label, period_start, period_end = _resolve_period_scope(year, quarter) + stats_map: Dict[int, Dict] = {} + + if partner_type == "customer": + order_rows = await db_session.execute( + select(SalesOrder, Customer) + .join(Customer, SalesOrder.customer_id == Customer.id) + .where(SalesOrder.order_date >= period_start) + .where(SalesOrder.order_date < period_end) + .where(Customer.is_active == True) + ) + for order, customer in order_rows.all(): + partner_stat = stats_map.setdefault( + customer.id, + { + "partner_id": customer.id, + "partner_name": customer.name, + "order_count": 0, + "transaction_count": 0, + "order_total": 0.0, + "settled_total": 0.0, + "transaction_total": 0.0, + "outstanding_total": 0.0, + }, + ) + total_amount = Decimal(str(order.total_amount or 0)) + settled_amount = Decimal(str(order.received_amount or 0)) + outstanding = max(total_amount - settled_amount, Decimal("0")) + partner_stat["order_count"] += 1 + partner_stat["order_total"] += total_amount + partner_stat["settled_total"] += settled_amount + partner_stat["outstanding_total"] += outstanding + + transaction_rows = await db_session.execute( + select(FinanceTransaction) + .where(FinanceTransaction.partner_type == "customer") + .where(FinanceTransaction.txn_type == "receipt") + .where(FinanceTransaction.status == "confirmed") + .where(FinanceTransaction.txn_date >= period_start) + .where(FinanceTransaction.txn_date < period_end) + ) + for txn in transaction_rows.scalars().all(): + partner_stat = stats_map.setdefault( + txn.partner_id, + { + "partner_id": txn.partner_id, + "partner_name": f"客户#{txn.partner_id}", + "order_count": 0, + "transaction_count": 0, + "order_total": 0.0, + "settled_total": 0.0, + "transaction_total": 0.0, + "outstanding_total": 0.0, + }, + ) + partner_stat["transaction_count"] += 1 + partner_stat["transaction_total"] += Decimal(str(txn.amount or 0)) + else: + order_rows = await db_session.execute( + select(PurchaseOrder, Supplier) + .join(Supplier, PurchaseOrder.supplier_id == Supplier.id) + .where(PurchaseOrder.order_date >= period_start) + .where(PurchaseOrder.order_date < period_end) + .where(Supplier.is_active == True) + ) + for order, supplier in order_rows.all(): + partner_stat = stats_map.setdefault( + supplier.id, + { + "partner_id": supplier.id, + "partner_name": supplier.name, + "order_count": 0, + "transaction_count": 0, + "order_total": 0.0, + "settled_total": 0.0, + "transaction_total": 0.0, + "outstanding_total": 0.0, + }, + ) + total_amount = Decimal(str(order.total_amount or 0)) + settled_amount = Decimal(str(order.paid_amount or 0)) + outstanding = max(total_amount - settled_amount, Decimal("0")) + partner_stat["order_count"] += 1 + partner_stat["order_total"] += total_amount + partner_stat["settled_total"] += settled_amount + partner_stat["outstanding_total"] += outstanding + + transaction_rows = await db_session.execute( + select(FinanceTransaction) + .where(FinanceTransaction.partner_type == "supplier") + .where(FinanceTransaction.txn_type == "payment") + .where(FinanceTransaction.status == "confirmed") + .where(FinanceTransaction.txn_date >= period_start) + .where(FinanceTransaction.txn_date < period_end) + ) + for txn in transaction_rows.scalars().all(): + partner_stat = stats_map.setdefault( + txn.partner_id, + { + "partner_id": txn.partner_id, + "partner_name": f"供应商#{txn.partner_id}", + "order_count": 0, + "transaction_count": 0, + "order_total": 0.0, + "settled_total": 0.0, + "transaction_total": 0.0, + "outstanding_total": 0.0, + }, + ) + partner_stat["transaction_count"] += 1 + partner_stat["transaction_total"] += Decimal(str(txn.amount or 0)) + + missing_partner_ids = [pid for pid, item in stats_map.items() if "#" in item["partner_name"]] + if missing_partner_ids: + if partner_type == "customer": + name_rows = await db_session.execute( + select(Customer.id, Customer.name).where(Customer.id.in_(missing_partner_ids)) + ) + else: + name_rows = await db_session.execute( + select(Supplier.id, Supplier.name).where(Supplier.id.in_(missing_partner_ids)) + ) + name_map = {row[0]: row[1] for row in name_rows.all()} + for pid in missing_partner_ids: + if pid in name_map: + stats_map[pid]["partner_name"] = name_map[pid] + + items = [ + PartnerStatementItemResponse( + partner_id=item["partner_id"], + partner_name=item["partner_name"], + order_count=item["order_count"], + transaction_count=item["transaction_count"], + order_total=Decimal(str(item["order_total"])), + settled_total=Decimal(str(item["settled_total"])), + transaction_total=Decimal(str(item["transaction_total"])), + outstanding_total=Decimal(str(item["outstanding_total"])), + period_year=selected_year, + period_quarter=selected_quarter, + ) + for item in sorted(stats_map.values(), key=lambda x: (x["outstanding_total"], x["order_total"]), reverse=True) + ] + + return FinancePartnerStatementResponse( + partner_type=partner_type, + year=selected_year, + quarter=selected_quarter, + period_label=period_label, + order_total=Decimal(str(sum(item.order_total for item in items))), + settled_total=Decimal(str(sum(item.settled_total for item in items))), + transaction_total=Decimal(str(sum(item.transaction_total for item in items))), + outstanding_total=Decimal(str(sum(item.outstanding_total for item in items))), + items=items, + ) + + +@router.get("/partner-product-statement/{partner_type}", response_model=FinancePartnerProductStatementResponse) +async def get_partner_product_statement( + partner_type: str, + partner_id: Optional[int] = Query(None, ge=1), + year: Optional[int] = Query(None, ge=2000, le=2100), + quarter: Optional[int] = Query(None, ge=1, le=4), + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user), +): + if partner_type not in ["customer", "supplier"]: + raise HTTPException(status_code=400, detail="partner_type 必须是 customer 或 supplier") + + selected_year, selected_quarter, period_label, period_start, period_end = _resolve_period_scope(year, quarter) + stats_map: Dict[Tuple[int, int], Dict] = {} + + if partner_type == "customer": + query = ( + select(SalesOrderItem, SalesOrder, Product, Customer) + .join(SalesOrder, SalesOrderItem.order_id == SalesOrder.id) + .join(Product, SalesOrderItem.product_id == Product.id) + .join(Customer, SalesOrder.customer_id == Customer.id) + .where(SalesOrder.order_date >= period_start) + .where(SalesOrder.order_date < period_end) + .where(Customer.is_active == True) + ) + if partner_id: + query = query.where(Customer.id == partner_id) + + result = await db_session.execute(query) + for item, order, product, customer in result.all(): + map_key = (customer.id, product.id) + stat = stats_map.setdefault( + map_key, + { + "partner_id": customer.id, + "partner_name": customer.name, + "product_id": product.id, + "product_sku": product.sku, + "product_name": product.name, + "order_ids": set(), + "order_quantity": 0.0, + "order_amount": 0.0, + "settled_amount": 0.0, + "outstanding_amount": 0.0, + }, + ) + + item_amount = Decimal(str(item.amount or 0)) + order_total = Decimal(str(order.total_amount or 0)) + order_settled = max(Decimal(str(order.received_amount or 0)), Decimal("0")) + ratio = (item_amount / order_total) if order_total > Decimal("1e-9") else Decimal("0") + item_settled = min(item_amount, order_settled * ratio) + item_outstanding = max(item_amount - item_settled, Decimal("0")) + + stat["order_ids"].add(order.id) + stat["order_quantity"] += Decimal(str(item.quantity or 0)) + stat["order_amount"] += item_amount + stat["settled_amount"] += item_settled + stat["outstanding_amount"] += item_outstanding + else: + query = ( + select(PurchaseOrderItem, PurchaseOrder, Product, Supplier) + .join(PurchaseOrder, PurchaseOrderItem.order_id == PurchaseOrder.id) + .join(Product, PurchaseOrderItem.product_id == Product.id) + .join(Supplier, PurchaseOrder.supplier_id == Supplier.id) + .where(PurchaseOrder.order_date >= period_start) + .where(PurchaseOrder.order_date < period_end) + .where(Supplier.is_active == True) + ) + if partner_id: + query = query.where(Supplier.id == partner_id) + + result = await db_session.execute(query) + for item, order, product, supplier in result.all(): + map_key = (supplier.id, product.id) + stat = stats_map.setdefault( + map_key, + { + "partner_id": supplier.id, + "partner_name": supplier.name, + "product_id": product.id, + "product_sku": product.sku, + "product_name": product.name, + "order_ids": set(), + "order_quantity": 0.0, + "order_amount": 0.0, + "settled_amount": 0.0, + "outstanding_amount": 0.0, + }, + ) + + item_amount = Decimal(str(item.amount or 0)) + order_total = Decimal(str(order.total_amount or 0)) + order_settled = max(Decimal(str(order.paid_amount or 0)), Decimal("0")) + ratio = (item_amount / order_total) if order_total > Decimal("1e-9") else Decimal("0") + item_settled = min(item_amount, order_settled * ratio) + item_outstanding = max(item_amount - item_settled, Decimal("0")) + + stat["order_ids"].add(order.id) + stat["order_quantity"] += Decimal(str(item.quantity or 0)) + stat["order_amount"] += item_amount + stat["settled_amount"] += item_settled + stat["outstanding_amount"] += item_outstanding + + items = [ + PartnerProductStatementItemResponse( + partner_id=item["partner_id"], + partner_name=item["partner_name"], + product_id=item["product_id"], + product_sku=item["product_sku"], + product_name=item["product_name"], + order_count=len(item["order_ids"]), + order_quantity=Decimal(str(item["order_quantity"])), + order_amount=Decimal(str(item["order_amount"])), + settled_amount=Decimal(str(item["settled_amount"])), + outstanding_amount=Decimal(str(item["outstanding_amount"])), + period_year=selected_year, + period_quarter=selected_quarter, + ) + for item in sorted( + stats_map.values(), + key=lambda x: (x["outstanding_amount"], x["order_amount"]), + reverse=True + ) + ] + + return FinancePartnerProductStatementResponse( + partner_type=partner_type, + year=selected_year, + quarter=selected_quarter, + period_label=period_label, + partner_id=partner_id, + order_amount_total=Decimal(str(sum(item.order_amount for item in items))), + settled_amount_total=Decimal(str(sum(item.settled_amount for item in items))), + outstanding_amount_total=Decimal(str(sum(item.outstanding_amount for item in items))), + items=items, + ) + diff --git a/src/api/inventory/inventory_routes.py b/src/api/inventory/inventory_routes.py new file mode 100644 index 0000000..50e3eaa --- /dev/null +++ b/src/api/inventory/inventory_routes.py @@ -0,0 +1,198 @@ +""" +库存管理路由模块 + +提供库存信息的查询功能,包括: +- 库存列表查询(支持分页、仓库筛选、产品筛选、低库存筛选) +- 显示产品库存数量、锁定数量、可用数量等信息 + +路由前缀: /api/inventory +""" +from fastapi import APIRouter, Depends, Query, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, update, func +from typing import Optional, List + +from database.database import get_db_session +from services.auth_service import get_current_active_user +from models.database import User, Product, Warehouse, Inventory +from .schemas import InventoryResponse, InventoryCreate, InventoryUpdate, PaginatedResponse + +router = APIRouter(prefix="/inventory", tags=["库存管理"]) + + +@router.get("", response_model=PaginatedResponse[InventoryResponse]) +async def list_inventory( + warehouse_id: Optional[int] = None, + product_id: Optional[int] = None, + low_stock: bool = False, + skip: int = Query(0, ge=0), + limit: int = Query(20, ge=1, le=100), + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + base_query = ( + select(Inventory, Product, Warehouse) + .join(Product, Inventory.product_id == Product.id) + .join(Warehouse, Inventory.warehouse_id == Warehouse.id) + .where(Product.is_active == True) + .where(Product.item_type == "material") + .where(Warehouse.is_active == True) + ) + + if warehouse_id: + base_query = base_query.where(Inventory.warehouse_id == warehouse_id) + if product_id: + base_query = base_query.where(Inventory.product_id == product_id) + if low_stock: + base_query = base_query.where(Inventory.quantity <= Product.min_stock) + + count_query = select(func.count()).select_from(base_query.subquery()) + total = await db_session.scalar(count_query) or 0 + + query = base_query.offset(skip).limit(limit) + result = await db_session.execute(query) + + inventory_list = [] + for inv, product, warehouse in result.all(): + inventory_list.append(InventoryResponse( + id=inv.id, + product_id=inv.product_id, + product_name=product.name, + product_sku=product.sku, + warehouse_id=inv.warehouse_id, + warehouse_name=warehouse.name, + quantity=inv.quantity, + locked_quantity=inv.locked_quantity, + available_quantity=inv.available_quantity + )) + + return PaginatedResponse(items=inventory_list, total=total, skip=skip, limit=limit) + + +@router.post("", response_model=InventoryResponse, status_code=201) +async def create_inventory( + payload: InventoryCreate, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + if payload.quantity < 0 or payload.locked_quantity < 0: + raise HTTPException(status_code=400, detail="库存数量不能为负数") + if payload.locked_quantity > payload.quantity: + raise HTTPException(status_code=400, detail="锁定数量不能大于库存数量") + + product_result = await db_session.execute( + select(Product).where(Product.id == payload.product_id, Product.is_active == True) + ) + product = product_result.scalar_one_or_none() + if not product: + raise HTTPException(status_code=404, detail="物料不存在") + if product.item_type != "material": + raise HTTPException(status_code=400, detail="库存仅支持物料") + + warehouse_result = await db_session.execute( + select(Warehouse).where(Warehouse.id == payload.warehouse_id, Warehouse.is_active == True) + ) + warehouse = warehouse_result.scalar_one_or_none() + if not warehouse: + raise HTTPException(status_code=404, detail="仓库不存在") + + exists_result = await db_session.execute( + select(Inventory).where( + Inventory.product_id == payload.product_id, + Inventory.warehouse_id == payload.warehouse_id + ) + ) + if exists_result.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="该仓库已存在该物料库存记录") + + inventory = Inventory( + product_id=payload.product_id, + warehouse_id=payload.warehouse_id, + quantity=payload.quantity, + locked_quantity=payload.locked_quantity, + batch_number=payload.batch_number, + location=payload.location + ) + db_session.add(inventory) + await db_session.commit() + await db_session.refresh(inventory) + + return InventoryResponse( + id=inventory.id, + product_id=product.id, + product_name=product.name, + product_sku=product.sku, + warehouse_id=warehouse.id, + warehouse_name=warehouse.name, + quantity=inventory.quantity, + locked_quantity=inventory.locked_quantity, + available_quantity=inventory.available_quantity + ) + + +@router.put("/{inventory_id}", response_model=InventoryResponse) +async def update_inventory( + inventory_id: int, + payload: InventoryUpdate, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + result = await db_session.execute( + select(Inventory, Product, Warehouse) + .join(Product, Inventory.product_id == Product.id) + .join(Warehouse, Inventory.warehouse_id == Warehouse.id) + .where(Inventory.id == inventory_id) + .where(Product.item_type == "material") + .with_for_update(of=Inventory) + ) + row = result.first() + if not row: + raise HTTPException(status_code=404, detail="库存记录不存在") + inventory, product, warehouse = row + + if payload.quantity is not None: + if payload.quantity < 0: + raise HTTPException(status_code=400, detail="库存数量不能为负数") + inventory.quantity = payload.quantity + if payload.locked_quantity is not None: + if payload.locked_quantity < 0: + raise HTTPException(status_code=400, detail="锁定数量不能为负数") + inventory.locked_quantity = payload.locked_quantity + if inventory.locked_quantity > inventory.quantity: + raise HTTPException(status_code=400, detail="锁定数量不能大于库存数量") + + if payload.batch_number is not None: + inventory.batch_number = payload.batch_number + if payload.location is not None: + inventory.location = payload.location + + await db_session.commit() + await db_session.refresh(inventory) + return InventoryResponse( + id=inventory.id, + product_id=product.id, + product_name=product.name, + product_sku=product.sku, + warehouse_id=warehouse.id, + warehouse_name=warehouse.name, + quantity=inventory.quantity, + locked_quantity=inventory.locked_quantity, + available_quantity=inventory.available_quantity + ) + + +@router.delete("/{inventory_id}") +async def delete_inventory( + inventory_id: int, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + result = await db_session.execute(select(Inventory).where(Inventory.id == inventory_id)) + inventory = result.scalar_one_or_none() + if not inventory: + raise HTTPException(status_code=404, detail="库存记录不存在") + if inventory.quantity > 0: + raise HTTPException(status_code=400, detail="库存数量不为零,无法删除库存记录") + await db_session.delete(inventory) + await db_session.commit() + return {"message": "库存记录已删除"} diff --git a/src/api/inventory/product_routes.py b/src/api/inventory/product_routes.py new file mode 100644 index 0000000..2bf9ea0 --- /dev/null +++ b/src/api/inventory/product_routes.py @@ -0,0 +1,260 @@ +""" +产品管理路由模块 + +提供产品信息的增删改查功能,包括: +- 产品列表查询(支持分页、搜索、分类筛选) +- 创建新产品(SKU唯一性校验) +- 更新产品信息 +- 删除产品(软删除,需要管理员权限) + +路由前缀: /api/products +""" +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, or_, func, delete +from typing import Optional, List, Dict +from decimal import Decimal + +from database.database import get_db_session +from services.auth_service import get_current_active_user, get_current_admin_user +from models.database import User, Product, ProductMaterial +from .schemas import ( + ProductCreate, + ProductResponse, + ProductBOMUpdate, + ProductBOMResponse, + ProductMaterialItemResponse +) + +router = APIRouter(prefix="/products", tags=["产品管理"]) + + +async def _calculate_material_cost_map(db_session: AsyncSession, product_ids: List[int]) -> Dict[int, float]: + if not product_ids: + return {} + result = await db_session.execute( + select( + ProductMaterial.finished_product_id, + func.coalesce( + func.sum( + Product.cost_price * ProductMaterial.quantity + ), + 0 + ) + ) + .join(Product, ProductMaterial.material_product_id == Product.id) + .where(ProductMaterial.finished_product_id.in_(product_ids)) + .group_by(ProductMaterial.finished_product_id) + ) + return {row[0]: Decimal(str(row[1] or 0)) for row in result.all()} + + +def _build_product_response(product: Product, material_cost: Decimal = Decimal("0")) -> ProductResponse: + return ProductResponse( + id=product.id, + sku=product.sku, + name=product.name, + description=product.description, + category=product.category, + unit=product.unit, + item_type=product.item_type, + cost_price=Decimal(str(product.cost_price or 0)), + sale_price=Decimal(str(product.sale_price or 0)), + min_stock=product.min_stock, + max_stock=product.max_stock, + material_cost=Decimal(str(material_cost)).quantize(Decimal("0.0001")), + is_active=product.is_active, + created_at=product.created_at, + ) + + +@router.get("", response_model=List[ProductResponse]) +async def list_products( + skip: int = Query(0, ge=0), + limit: int = Query(20, ge=1, le=100), + search: Optional[str] = None, + category: Optional[str] = None, + item_type: Optional[str] = None, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + query = select(Product).where(Product.is_active == True) + + if search: + query = query.where(or_(Product.name.ilike(f"%{search}%"), Product.sku.ilike(f"%{search}%"))) + if category: + query = query.where(Product.category == category) + if item_type: + query = query.where(Product.item_type == item_type) + + query = query.offset(skip).limit(limit).order_by(Product.created_at.desc()) + result = await db_session.execute(query) + products = result.scalars().all() + finished_product_ids = [p.id for p in products if p.item_type == "finished"] + material_cost_map = await _calculate_material_cost_map(db_session, finished_product_ids) + return [_build_product_response(p, material_cost_map.get(p.id, 0)) for p in products] + + +@router.post("", response_model=ProductResponse, status_code=201) +async def create_product( + product_data: ProductCreate, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + if product_data.item_type not in ["material", "finished"]: + raise HTTPException(status_code=400, detail="item_type 必须为 material 或 finished") + existing = await db_session.execute(select(Product).where(Product.sku == product_data.sku)) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="SKU已存在") + + product_dict = product_data.dict() + if product_data.item_type == "finished": + product_dict["min_stock"] = 0 + product_dict["max_stock"] = 0 + product = Product(**product_dict) + db_session.add(product) + await db_session.commit() + await db_session.refresh(product) + return _build_product_response(product, 0) + + +@router.put("/{product_id}", response_model=ProductResponse) +async def update_product( + product_id: int, + product_data: ProductCreate, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + result = await db_session.execute(select(Product).where(Product.id == product_id)) + product = result.scalar_one_or_none() + if not product: + raise HTTPException(status_code=404, detail="产品不存在") + if product_data.item_type not in ["material", "finished"]: + raise HTTPException(status_code=400, detail="item_type 必须为 material 或 finished") + + product_dict = product_data.dict() + if product_data.item_type == "finished": + product_dict["min_stock"] = 0 + product_dict["max_stock"] = 0 + + for key, value in product_dict.items(): + setattr(product, key, value) + + await db_session.commit() + await db_session.refresh(product) + material_cost_map = await _calculate_material_cost_map(db_session, [product.id]) + return _build_product_response(product, material_cost_map.get(product.id, 0)) + + +@router.delete("/{product_id}") +async def delete_product( + product_id: int, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_admin_user) +): + result = await db_session.execute(select(Product).where(Product.id == product_id)) + product = result.scalar_one_or_none() + if not product: + raise HTTPException(status_code=404, detail="产品不存在") + + product.is_active = False + await db_session.commit() + return {"message": "产品已删除"} + + +@router.get("/{product_id}/materials", response_model=ProductBOMResponse) +async def get_product_bom( + product_id: int, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + product_result = await db_session.execute( + select(Product).where(Product.id == product_id, Product.is_active == True) + ) + product = product_result.scalar_one_or_none() + if not product: + raise HTTPException(status_code=404, detail="产品不存在") + if product.item_type != "finished": + raise HTTPException(status_code=400, detail="仅成品支持配置物料BOM") + + bom_result = await db_session.execute( + select(ProductMaterial, Product) + .join(Product, ProductMaterial.material_product_id == Product.id) + .where(ProductMaterial.finished_product_id == product_id) + .order_by(ProductMaterial.id.asc()) + ) + + items: List[ProductMaterialItemResponse] = [] + total_material_cost = Decimal("0") + for bom, material in bom_result.all(): + line_cost = Decimal(str(material.cost_price or 0)) * Decimal(str(bom.quantity)) + total_material_cost += line_cost + items.append( + ProductMaterialItemResponse( + material_id=material.id, + material_sku=material.sku, + material_name=material.name, + quantity=Decimal(str(bom.quantity)), + unit_cost=Decimal(str(material.cost_price or 0)), + line_cost=line_cost, + ) + ) + + return ProductBOMResponse( + product_id=product.id, + product_name=product.name, + total_material_cost=total_material_cost, + items=items, + ) + + +@router.put("/{product_id}/materials", response_model=ProductBOMResponse) +async def replace_product_bom( + product_id: int, + payload: ProductBOMUpdate, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + product_result = await db_session.execute( + select(Product).where(Product.id == product_id, Product.is_active == True) + ) + product = product_result.scalar_one_or_none() + if not product: + raise HTTPException(status_code=404, detail="产品不存在") + if product.item_type != "finished": + raise HTTPException(status_code=400, detail="仅成品支持配置物料BOM") + + material_ids = [item.material_id for item in payload.items] + if len(material_ids) != len(set(material_ids)): + raise HTTPException(status_code=400, detail="BOM 物料不允许重复") + + if material_ids: + material_result = await db_session.execute( + select(Product).where(Product.id.in_(material_ids), Product.is_active == True) + ) + materials = material_result.scalars().all() + material_map = {m.id: m for m in materials} + if len(material_map) != len(material_ids): + raise HTTPException(status_code=400, detail="存在无效物料") + invalid_materials = [m.name for m in materials if m.item_type != "material"] + if invalid_materials: + raise HTTPException(status_code=400, detail=f"以下条目不是物料:{', '.join(invalid_materials)}") + else: + material_map = {} + + await db_session.execute(delete(ProductMaterial).where(ProductMaterial.finished_product_id == product_id)) + + for item in payload.items: + if item.quantity <= 0: + raise HTTPException(status_code=400, detail="物料数量必须大于 0") + db_session.add( + ProductMaterial( + finished_product_id=product_id, + material_product_id=item.material_id, + quantity=item.quantity, + loss_rate=item.loss_rate, + ) + ) + + await db_session.commit() + return await get_product_bom(product_id, db_session, current_user) diff --git a/src/api/inventory/purchase_order_routes.py b/src/api/inventory/purchase_order_routes.py new file mode 100644 index 0000000..d5c1a31 --- /dev/null +++ b/src/api/inventory/purchase_order_routes.py @@ -0,0 +1,438 @@ +""" +采购订单路由模块 + +提供采购订单的管理功能,包括: +- 采购订单列表查询(支持分页、状态筛选) +- 创建采购订单(自动生成订单号、计算总金额) +- 采购订单明细管理 + +路由前缀: /api/purchase-orders +""" +from fastapi import APIRouter, Depends, Query, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, update +from typing import Optional, List +from decimal import Decimal + +from database.database import get_db_session +from services.auth_service import get_current_active_user +from models.database import ( + User, + Supplier, + Product, + Warehouse, + Inventory, + StockMovement, + PurchaseOrder, + PurchaseOrderItem +) +from .schemas import ( + PurchaseOrderCreate, + PurchaseOrderResponse, + PurchaseOrderDetailResponse, + PurchaseOrderItemResponse, + PurchaseOrderReceiveRequest, + PaginatedResponse, +) +from .utils import generate_order_no + +router = APIRouter(prefix="/purchase-orders", tags=["采购订单"]) + + +def _build_purchase_order_response(order: PurchaseOrder, supplier_name: str) -> PurchaseOrderResponse: + return PurchaseOrderResponse( + id=order.id, + order_no=order.order_no, + supplier_name=supplier_name, + order_date=order.order_date, + expected_date=order.expected_date, + status=order.status, + total_amount=order.total_amount, + paid_amount=order.paid_amount, + remark=order.remark, + created_at=order.created_at, + received_date=order.received_date, + paid_date=order.paid_date + ) + + +async def _get_order_with_supplier( + db_session: AsyncSession, + order_id: int +) -> tuple[PurchaseOrder, Supplier]: + result = await db_session.execute( + select(PurchaseOrder, Supplier) + .join(Supplier, PurchaseOrder.supplier_id == Supplier.id) + .where(PurchaseOrder.id == order_id) + ) + row = result.first() + if not row: + raise HTTPException(status_code=404, detail="采购订单不存在") + return row[0], row[1] + + +async def _build_purchase_order_detail( + db_session: AsyncSession, + order: PurchaseOrder, + supplier_name: str +) -> PurchaseOrderDetailResponse: + item_result = await db_session.execute( + select(PurchaseOrderItem, Product) + .join(Product, PurchaseOrderItem.product_id == Product.id) + .where(PurchaseOrderItem.order_id == order.id) + .order_by(PurchaseOrderItem.id.asc()) + ) + item_rows = item_result.all() + + return PurchaseOrderDetailResponse( + id=order.id, + order_no=order.order_no, + supplier_id=order.supplier_id, + supplier_name=supplier_name, + order_date=order.order_date, + expected_date=order.expected_date, + status=order.status, + total_amount=order.total_amount, + paid_amount=order.paid_amount, + remark=order.remark, + created_at=order.created_at, + received_date=order.received_date, + paid_date=order.paid_date, + items=[ + PurchaseOrderItemResponse( + id=item.id, + product_id=item.product_id, + product_sku=product.sku, + product_name=product.name, + quantity=item.quantity, + received_quantity=item.received_quantity, + unit_price=item.unit_price, + amount=item.amount, + remark=item.remark + ) for item, product in item_rows + ] + ) + + +async def _resolve_receive_warehouse( + db_session: AsyncSession, + warehouse_id: Optional[int] +) -> Warehouse: + if warehouse_id: + result = await db_session.execute( + select(Warehouse).where(Warehouse.id == warehouse_id, Warehouse.is_active == True) + ) + warehouse = result.scalar_one_or_none() + if not warehouse: + raise HTTPException(status_code=404, detail="仓库不存在") + return warehouse + + result = await db_session.execute( + select(Warehouse).where(Warehouse.is_active == True).order_by(Warehouse.is_default.desc(), Warehouse.id.asc()) + ) + warehouse = result.scalars().first() + if not warehouse: + raise HTTPException(status_code=400, detail="未配置可用仓库") + return warehouse + + +async def _apply_order_items( + db_session: AsyncSession, + order: PurchaseOrder, + order_data: PurchaseOrderCreate +) -> Decimal: + total_amount = Decimal("0") + for item_data in order_data.items: + product_result = await db_session.execute( + select(Product).where(Product.id == item_data.product_id, Product.is_active == True) + ) + product = product_result.scalar_one_or_none() + if not product: + raise HTTPException(status_code=400, detail=f"物料不存在: {item_data.product_id}") + if product.item_type != "material": + raise HTTPException(status_code=400, detail=f"采购单仅允许物料: {product.name}") + + # 使用物料的成本价格作为单价,忽略前端提交的单价 + unit_price = product.cost_price or 0 + if unit_price <= 0: + raise HTTPException(status_code=400, detail=f"物料 {product.name} 未设置成本价格,请在物料管理界面设置") + + try: + item = PurchaseOrderItem( + order_id=order.id, + product_id=item_data.product_id, + quantity=int(item_data.quantity), + unit_price=Decimal(str(unit_price)), + amount=Decimal(str(item_data.quantity)) * Decimal(str(unit_price)), + remark=item_data.remark or None + ) + db_session.add(item) + total_amount += item.amount + except Exception as e: + raise HTTPException(status_code=400, detail=f"创建订单明细失败: {str(e)}") + return total_amount + + +@router.get("", response_model=PaginatedResponse[PurchaseOrderResponse]) +async def list_purchase_orders( + status: Optional[str] = None, + skip: int = Query(0, ge=0), + limit: int = Query(20, ge=1, le=100), + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + base_query = ( + select(PurchaseOrder, Supplier) + .join(Supplier, PurchaseOrder.supplier_id == Supplier.id) + .order_by(PurchaseOrder.created_at.desc()) + ) + + if status: + base_query = base_query.where(PurchaseOrder.status == status) + + count_query = select(func.count()).select_from(base_query.subquery()) + total = await db_session.scalar(count_query) or 0 + + query = base_query.offset(skip).limit(limit) + result = await db_session.execute(query) + + orders = [] + for order, supplier in result.all(): + orders.append(_build_purchase_order_response(order, supplier.name)) + + return PaginatedResponse(items=orders, total=total, skip=skip, limit=limit) + + +@router.post("", response_model=PurchaseOrderResponse, status_code=201) +async def create_purchase_order( + order_data: PurchaseOrderCreate, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + order = PurchaseOrder( + order_no=generate_order_no("PO"), + supplier_id=order_data.supplier_id, + expected_date=order_data.expected_date, + remark=order_data.remark, + operator_id=current_user.id, + status="pending" + ) + db_session.add(order) + await db_session.flush() + + try: + order.total_amount = await _apply_order_items(db_session, order, order_data) + await db_session.commit() + except (HTTPException, Exception): + await db_session.rollback() + raise + await db_session.refresh(order) + + supplier_result = await db_session.execute(select(Supplier).where(Supplier.id == order.supplier_id)) + supplier = supplier_result.scalar_one_or_none() + if not supplier: + raise HTTPException(status_code=404, detail="供应商不存在") + + return _build_purchase_order_response(order, supplier.name) + + +@router.get("/{order_id}", response_model=PurchaseOrderDetailResponse) +async def get_purchase_order_detail( + order_id: int, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + order, supplier = await _get_order_with_supplier(db_session, order_id) + return await _build_purchase_order_detail(db_session, order, supplier.name) + + +@router.put("/{order_id}", response_model=PurchaseOrderResponse) +async def update_purchase_order( + order_id: int, + order_data: PurchaseOrderCreate, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + order, _ = await _get_order_with_supplier(db_session, order_id) + if order.paid_amount and order.paid_amount > 0: + raise HTTPException(status_code=400, detail="已付款采购单不允许修改") + + item_result = await db_session.execute( + select(PurchaseOrderItem).where(PurchaseOrderItem.order_id == order.id) + ) + existing_items = item_result.scalars().all() + if any((item.received_quantity or 0) > 0 for item in existing_items): + raise HTTPException(status_code=400, detail="已发生入库的采购单不允许直接修改") + + for item in existing_items: + await db_session.delete(item) + + try: + order.supplier_id = order_data.supplier_id + order.expected_date = order_data.expected_date + order.remark = order_data.remark + order.total_amount = await _apply_order_items(db_session, order, order_data) + order.status = "pending" + + await db_session.commit() + except (HTTPException, Exception): + await db_session.rollback() + raise + await db_session.refresh(order) + supplier_result = await db_session.execute(select(Supplier).where(Supplier.id == order.supplier_id)) + supplier = supplier_result.scalar_one_or_none() + return _build_purchase_order_response(order, supplier.name if supplier else "未知供应商") + + +@router.delete("/{order_id}") +async def delete_purchase_order( + order_id: int, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + order, _ = await _get_order_with_supplier(db_session, order_id) + if order.paid_amount and order.paid_amount > 0: + raise HTTPException(status_code=400, detail="已付款采购单不允许删除") + item_result = await db_session.execute( + select(PurchaseOrderItem).where(PurchaseOrderItem.order_id == order.id) + ) + if any((item.received_quantity or 0) > 0 for item in item_result.scalars().all()): + raise HTTPException(status_code=400, detail="已发生入库的采购单不允许删除") + await db_session.delete(order) + await db_session.commit() + return {"message": "采购订单已删除"} + + +@router.patch("/{order_id}/status") +async def update_purchase_order_status( + order_id: int, + status: dict, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + order, _ = await _get_order_with_supplier(db_session, order_id) + + new_status = status.get("status") + if not new_status: + raise HTTPException(status_code=400, detail="状态不能为空") + + valid_statuses = ["pending", "partial_received", "received", "paid"] + if new_status not in valid_statuses: + raise HTTPException(status_code=400, detail=f"无效的状态值,有效值为: {valid_statuses}") + + # 状态转换逻辑 + if order.status == "paid": + raise HTTPException(status_code=400, detail="已付款的采购订单禁止修改状态") + + if order.status == "received" and new_status != "paid": + raise HTTPException(status_code=400, detail="已收货的采购订单只能标记为已付款") + + if order.status == "partial_received" and new_status not in ("received", "paid"): + raise HTTPException(status_code=400, detail="部分收货的采购订单只能标记为已收货或已付款") + + # 更新状态和对应时间 + order.status = new_status + if new_status == "received": + order.received_date = func.now() + elif new_status == "paid": + order.paid_date = func.now() + + await db_session.commit() + await db_session.refresh(order) + + supplier_result = await db_session.execute(select(Supplier).where(Supplier.id == order.supplier_id)) + supplier = supplier_result.scalar_one_or_none() + return _build_purchase_order_response(order, supplier.name if supplier else "未知供应商") + + +@router.post("/{order_id}/receive", response_model=PurchaseOrderDetailResponse) +async def receive_purchase_order( + order_id: int, + payload: PurchaseOrderReceiveRequest, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + order, supplier = await _get_order_with_supplier(db_session, order_id) + warehouse = await _resolve_receive_warehouse(db_session, payload.warehouse_id) + + item_result = await db_session.execute( + select(PurchaseOrderItem).where(PurchaseOrderItem.order_id == order.id) + ) + item_map = {item.id: item for item in item_result.scalars().all()} + if not item_map: + raise HTTPException(status_code=400, detail="采购单无明细,无法入库") + + if not payload.items: + raise HTTPException(status_code=400, detail="请提供本次入库明细") + + for receive_item in payload.items: + item = item_map.get(receive_item.item_id) + if not item: + raise HTTPException(status_code=400, detail=f"采购明细不存在: {receive_item.item_id}") + if receive_item.receive_quantity <= 0: + raise HTTPException(status_code=400, detail="入库数量必须大于0") + remaining_qty = (item.quantity or 0) - (item.received_quantity or 0) + if receive_item.receive_quantity > remaining_qty: + raise HTTPException(status_code=400, detail=f"明细{item.id}入库超量,剩余可入库{remaining_qty}") + + for receive_item in payload.items: + item = item_map[receive_item.item_id] + product_result = await db_session.execute( + select(Product).where(Product.id == item.product_id) + ) + product = product_result.scalar_one_or_none() + if not product: + raise HTTPException(status_code=400, detail=f"物料不存在: {item.product_id}") + + upd_result = await db_session.execute( + update(Inventory) + .where(Inventory.product_id == item.product_id) + .where(Inventory.warehouse_id == warehouse.id) + .values(quantity=Inventory.quantity + receive_item.receive_quantity) + .returning(Inventory.quantity) + ) + after_qty = upd_result.scalar_one_or_none() + if after_qty is None: + inventory = Inventory( + product_id=item.product_id, + warehouse_id=warehouse.id, + quantity=receive_item.receive_quantity, + locked_quantity=0 + ) + db_session.add(inventory) + await db_session.flush() + before_qty = 0 + after_qty = receive_item.receive_quantity + else: + after_qty = int(after_qty) + before_qty = after_qty - receive_item.receive_quantity + item.received_quantity = (item.received_quantity or 0) + receive_item.receive_quantity + + movement = StockMovement( + product_id=item.product_id, + warehouse_id=warehouse.id, + movement_type="purchase_in", + quantity=receive_item.receive_quantity, + before_quantity=before_qty, + after_quantity=after_qty, + reference_type="purchase_order", + reference_id=order.id, + reference_no=order.order_no, + unit_price=item.unit_price, + total_amount=Decimal(str(item.unit_price * receive_item.receive_quantity)), + remark=payload.remark or f"采购单{order.order_no}到货入库", + operator_id=current_user.id + ) + db_session.add(movement) + + all_received = all((item.received_quantity or 0) >= (item.quantity or 0) for item in item_map.values()) + any_received = any((item.received_quantity or 0) > 0 for item in item_map.values()) + if all_received: + order.status = "received" + order.received_date = func.now() + elif any_received: + order.status = "partial_received" + + await db_session.commit() + await db_session.refresh(order) + return await _build_purchase_order_detail(db_session, order, supplier.name) diff --git a/src/api/inventory/sales_order_routes.py b/src/api/inventory/sales_order_routes.py new file mode 100644 index 0000000..a650a13 --- /dev/null +++ b/src/api/inventory/sales_order_routes.py @@ -0,0 +1,737 @@ +""" +销售订单路由模块 + +提供销售订单的管理功能,包括: +- 销售订单列表查询(支持分页、状态筛选) +- 创建销售订单(自动生成订单号、计算总金额) +- 销售订单明细管理 + +路由前缀: /api/sales-orders +""" +from fastapi import APIRouter, Depends, Query, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, delete, update +from typing import Optional, List +from math import ceil +from pydantic import BaseModel, Field +from decimal import Decimal + +from database.database import get_db_session +from services.auth_service import get_current_active_user +from models.database import ( + User, + Customer, + Product, + ProductMaterial, + Warehouse, + Inventory, + StockMovement, + SalesOrder, + SalesOrderItem +) +from .schemas import ( + SalesOrderCreate, + SalesOrderResponse, + SalesOrderDetailResponse, + SalesOrderItemResponse, + SalesOrderProductionPlanResponse, + ProductionMaterialPlanItemResponse, + SalesOrderIssueRequest, + SalesOrderIssueResponse, + SalesOrderStatusUpdate, + PaginatedResponse, +) +from .utils import generate_order_no + +router = APIRouter(prefix="/sales-orders", tags=["销售订单"]) +VALID_ORDER_STATUSES = {"draft", "manufacturing", "delivered", "paid"} +PRODUCTION_STATUSES = {"not_started", "bom_missing", "material_issued", "completed"} + + +def _build_sales_order_response(order: SalesOrder, customer_name: str) -> SalesOrderResponse: + return SalesOrderResponse( + id=order.id, + order_no=order.order_no, + customer_name=customer_name, + order_date=order.order_date, + delivery_date=order.delivery_date, + manufacturing_date=order.manufacturing_date, + actual_delivery_date=order.actual_delivery_date, + actual_payment_date=order.actual_payment_date, + status=order.status, + production_status=order.production_status or "not_started", + production_no=order.production_no, + planned_material_cost=Decimal(str(order.planned_material_cost or 0)), + actual_material_cost=Decimal(str(order.actual_material_cost or 0)), + total_amount=Decimal(str(order.total_amount or 0)), + received_amount=Decimal(str(order.received_amount or 0)), + remark=order.remark, + created_at=order.created_at + ) + + +async def _build_sales_order_detail_response( + db_session: AsyncSession, + order: SalesOrder, + customer_name: str +) -> SalesOrderDetailResponse: + items_result = await db_session.execute( + select(SalesOrderItem).where(SalesOrderItem.order_id == order.id).order_by(SalesOrderItem.id.asc()) + ) + items = items_result.scalars().all() + return SalesOrderDetailResponse( + id=order.id, + order_no=order.order_no, + customer_id=order.customer_id, + customer_name=customer_name, + order_date=order.order_date, + delivery_date=order.delivery_date, + manufacturing_date=order.manufacturing_date, + actual_delivery_date=order.actual_delivery_date, + actual_payment_date=order.actual_payment_date, + status=order.status, + production_status=order.production_status or "not_started", + production_no=order.production_no, + planned_material_cost=Decimal(str(order.planned_material_cost or 0)), + actual_material_cost=Decimal(str(order.actual_material_cost or 0)), + total_amount=Decimal(str(order.total_amount or 0)), + received_amount=Decimal(str(order.received_amount or 0)), + remark=order.remark, + created_at=order.created_at, + items=[ + SalesOrderItemResponse( + id=item.id, + product_id=item.product_id, + quantity=item.quantity, + delivered_quantity=item.delivered_quantity, + unit_price=Decimal(str(item.unit_price or 0)), + amount=Decimal(str(item.amount or 0)), + remark=item.remark + ) for item in items + ] + ) + + +async def _get_sales_order_with_customer( + db_session: AsyncSession, + order_id: int, +) -> tuple[SalesOrder, Customer]: + result = await db_session.execute( + select(SalesOrder, Customer) + .join(Customer, SalesOrder.customer_id == Customer.id) + .where(SalesOrder.id == order_id) + ) + row = result.first() + if not row: + raise HTTPException(status_code=404, detail="销售订单不存在") + return row[0], row[1] + + +async def _build_material_plan(db_session: AsyncSession, order: SalesOrder) -> tuple[List[ProductionMaterialPlanItemResponse], float]: + item_result = await db_session.execute( + select(SalesOrderItem).where(SalesOrderItem.order_id == order.id) + ) + order_items = item_result.scalars().all() + if not order_items: + return [], 0 + + finished_ids = list({int(i.product_id) for i in order_items}) + bom_result = await db_session.execute( + select(ProductMaterial, Product) + .join(Product, ProductMaterial.material_product_id == Product.id) + .where(ProductMaterial.finished_product_id.in_(finished_ids)) + .where(Product.is_active == True) + .where(Product.item_type == "material") + ) + bom_rows = bom_result.all() + if not bom_rows: + return [], 0 + + bom_by_finished_id = {} + for bom, material in bom_rows: + bom_by_finished_id.setdefault(int(bom.finished_product_id), []).append((bom, material)) + + required_qty_map = {} + for order_item in order_items: + bom_items = bom_by_finished_id.get(int(order_item.product_id)) or [] + for bom, material in bom_items: + qty = Decimal(str(order_item.quantity)) * Decimal(str(bom.quantity or 0)) * (1 + Decimal(str(bom.loss_rate or 0))) + entry = required_qty_map.setdefault(material.id, {"material": material, "required_qty": Decimal("0")}) + entry["required_qty"] += qty + + if not required_qty_map: + return [], Decimal("0") + + material_ids = list(required_qty_map.keys()) + stock_result = await db_session.execute( + select(Inventory.product_id, func.coalesce(func.sum(Inventory.quantity), 0)) + .where(Inventory.product_id.in_(material_ids)) + .group_by(Inventory.product_id) + ) + stock_map = {row[0]: Decimal(str(row[1] or 0)) for row in stock_result.all()} + + plan_items = [] + planned_material_cost = Decimal("0") + for material_id, entry in required_qty_map.items(): + material = entry["material"] + required_qty = int(ceil(entry["required_qty"])) + available_qty = stock_map.get(material_id, Decimal("0")) + shortage_qty = max(required_qty - int(available_qty), 0) + unit_cost = Decimal(str(material.cost_price or 0)) + required_cost = Decimal(str(required_qty)) * unit_cost + planned_material_cost += required_cost + plan_items.append( + ProductionMaterialPlanItemResponse( + material_id=material.id, + material_sku=material.sku, + material_name=material.name, + required_quantity=Decimal(str(required_qty)), + available_quantity=available_qty, + shortage_quantity=Decimal(str(shortage_qty)), + unit_cost=unit_cost, + required_cost=required_cost, + ) + ) + + plan_items = sorted(plan_items, key=lambda x: (x.shortage_quantity, x.required_cost), reverse=True) + return plan_items, planned_material_cost + + +async def _get_default_warehouse(db_session: AsyncSession) -> Warehouse: + warehouse_result = await db_session.execute( + select(Warehouse).where(Warehouse.is_active == True).order_by(Warehouse.is_default.desc(), Warehouse.id.asc()) + ) + warehouse = warehouse_result.scalars().first() + if not warehouse: + raise HTTPException(status_code=400, detail="未配置可用仓库,无法自动扣减物料") + return warehouse + + +async def _issue_materials_for_order_creation( + db_session: AsyncSession, + order: SalesOrder, + current_user: User +) -> tuple[int, float, float]: + warehouse = await _get_default_warehouse(db_session) + plan_items, planned_material_cost = await _build_material_plan(db_session, order) + if not plan_items: + order.production_no = order.production_no or generate_order_no("WO") + order.production_status = "bom_missing" + order.planned_material_cost = 0 + order.actual_material_cost = 0 + order.status = "manufacturing" + return 0, 0, 0 + shortage_items = [item for item in plan_items if item.shortage_quantity > 0] + if shortage_items: + shortage_text = ",".join([f"{item.material_name} 缺 {item.shortage_quantity}" for item in shortage_items]) + raise HTTPException(status_code=400, detail=f"物料库存不足:{shortage_text}") + + production_no = order.production_no or generate_order_no("WO") + actual_material_cost = Decimal("0") + movement_count = 0 + + for item in plan_items: + upd_result = await db_session.execute( + update(Inventory) + .where(Inventory.product_id == item.material_id) + .where(Inventory.warehouse_id == warehouse.id) + .where(Inventory.quantity >= item.required_quantity) + .values(quantity=Inventory.quantity - item.required_quantity) + .returning(Inventory.quantity) + ) + after_qty = upd_result.scalar_one_or_none() + if after_qty is None: + raise HTTPException(status_code=400, detail=f"{item.material_name} 在默认仓库库存不足") + after_qty = int(after_qty) + before_qty = after_qty + int(item.required_quantity) + total_amount = item.required_quantity * item.unit_cost + actual_material_cost += total_amount + + movement = StockMovement( + product_id=item.material_id, + warehouse_id=warehouse.id, + movement_type="issue_to_production", + quantity=item.required_quantity, + before_quantity=before_qty, + after_quantity=after_qty, + reference_type="sales_order", + reference_id=order.id, + reference_no=production_no, + unit_price=item.unit_cost, + total_amount=Decimal(str(total_amount)), + remark=f"销售单{order.order_no}创建时自动扣减物料", + operator_id=current_user.id + ) + db_session.add(movement) + movement_count += 1 + + order.production_no = production_no + order.production_status = "material_issued" + order.planned_material_cost = planned_material_cost + order.actual_material_cost = actual_material_cost + order.status = "manufacturing" + + return movement_count, planned_material_cost, actual_material_cost + + +async def _rollback_issued_materials( + db_session: AsyncSession, + order: SalesOrder, + current_user: User +): + movement_result = await db_session.execute( + select(StockMovement) + .where(StockMovement.reference_type == "sales_order") + .where(StockMovement.reference_id == order.id) + .where(StockMovement.movement_type == "issue_to_production") + .order_by(StockMovement.id.asc()) + ) + movements = movement_result.scalars().all() + if not movements: + return + + for movement in movements: + upd_result = await db_session.execute( + update(Inventory) + .where(Inventory.product_id == movement.product_id) + .where(Inventory.warehouse_id == movement.warehouse_id) + .values(quantity=Inventory.quantity + movement.quantity) + .returning(Inventory.quantity) + ) + after_qty = upd_result.scalar_one_or_none() + if after_qty is None: + inventory = Inventory( + product_id=movement.product_id, + warehouse_id=movement.warehouse_id, + quantity=movement.quantity, + locked_quantity=0 + ) + db_session.add(inventory) + await db_session.flush() + before_qty = 0 + after_qty = movement.quantity + else: + after_qty = int(after_qty) + before_qty = after_qty - movement.quantity + + revert_movement = StockMovement( + product_id=movement.product_id, + warehouse_id=movement.warehouse_id, + movement_type="return_from_production", + quantity=movement.quantity, + before_quantity=before_qty, + after_quantity=after_qty, + reference_type="sales_order", + reference_id=order.id, + reference_no=order.production_no or order.order_no, + unit_price=movement.unit_price, + total_amount=movement.total_amount, + remark=f"销售单{order.order_no}变更/删除,自动回补物料", + operator_id=current_user.id + ) + db_session.add(revert_movement) + + +async def _apply_order_items( + db_session: AsyncSession, + order: SalesOrder, + order_data: SalesOrderCreate +) -> Decimal: + total_amount = Decimal("0") + for item_data in order_data.items: + product = None + if item_data.product_id is not None: + product_result = await db_session.execute( + select(Product).where(Product.id == item_data.product_id, Product.is_active == True) + ) + product = product_result.scalar_one_or_none() + if not product: + raise HTTPException(status_code=400, detail=f"产品不存在: {item_data.product_id}") + else: + if not item_data.product_sku or not item_data.product_name: + raise HTTPException(status_code=400, detail="请提供产品ID,或提供产品SKU与产品名称") + by_sku_result = await db_session.execute( + select(Product).where(Product.sku == item_data.product_sku, Product.is_active == True) + ) + product = by_sku_result.scalar_one_or_none() + if not product: + product = Product( + sku=item_data.product_sku, + name=item_data.product_name, + category=item_data.product_category, + unit=item_data.product_unit or "件", + item_type="finished", + cost_price=0, + sale_price=item_data.unit_price or 0, + min_stock=0, + max_stock=0 + ) + db_session.add(product) + await db_session.flush() + if product.item_type != "finished": + raise HTTPException(status_code=400, detail=f"销售单仅允许成品: {product.name}") + item = SalesOrderItem( + order_id=order.id, + product_id=product.id, + quantity=item_data.quantity, + unit_price=item_data.unit_price, + amount=item_data.quantity * item_data.unit_price, + remark=item_data.remark + ) + db_session.add(item) + total_amount += item.amount + return total_amount + + +@router.get("", response_model=PaginatedResponse[SalesOrderResponse]) +async def list_sales_orders( + status: Optional[str] = None, + skip: int = Query(0, ge=0), + limit: int = Query(20, ge=1, le=100), + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + base_query = ( + select(SalesOrder, Customer) + .join(Customer, SalesOrder.customer_id == Customer.id) + .order_by(SalesOrder.created_at.desc()) + ) + + if status: + base_query = base_query.where(SalesOrder.status == status) + + count_query = select(func.count()).select_from(base_query.subquery()) + total = await db_session.scalar(count_query) or 0 + + query = base_query.offset(skip).limit(limit) + result = await db_session.execute(query) + + orders = [] + for order, customer in result.all(): + orders.append(_build_sales_order_response(order, customer.name)) + + return PaginatedResponse(items=orders, total=total, skip=skip, limit=limit) + + +@router.post("", response_model=SalesOrderResponse, status_code=201) +async def create_sales_order( + order_data: SalesOrderCreate, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + from datetime import datetime + now = datetime.now() + order = SalesOrder( + order_no=generate_order_no("SO"), + customer_id=order_data.customer_id, + order_date=now, + delivery_date=order_data.delivery_date, + manufacturing_date=now, + created_at=now, + remark=order_data.remark, + operator_id=current_user.id, + status="manufacturing" + ) + db_session.add(order) + await db_session.flush() + + try: + order.total_amount = await _apply_order_items(db_session, order, order_data) + await _issue_materials_for_order_creation(db_session, order, current_user) + await db_session.commit() + except (HTTPException, Exception): + await db_session.rollback() + raise + await db_session.refresh(order) + + customer = await db_session.execute(select(Customer).where(Customer.id == order.customer_id)) + customer = customer.scalar_one() + + return _build_sales_order_response(order, customer.name) + + +@router.get("/{order_id}", response_model=SalesOrderDetailResponse) +async def get_sales_order_detail( + order_id: int, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + order, customer = await _get_sales_order_with_customer(db_session, order_id) + return await _build_sales_order_detail_response(db_session, order, customer.name) + + +@router.put("/{order_id}", response_model=SalesOrderResponse) +async def update_sales_order( + order_id: int, + order_data: SalesOrderCreate, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + order, customer = await _get_sales_order_with_customer(db_session, order_id) + if order.status == "paid": + raise HTTPException(status_code=400, detail="已收款的销售订单禁止修改") + try: + await _rollback_issued_materials(db_session, order, current_user) + await db_session.execute(delete(SalesOrderItem).where(SalesOrderItem.order_id == order.id)) + + order.customer_id = order_data.customer_id + order.delivery_date = order_data.delivery_date + order.remark = order_data.remark + order.production_status = "not_started" + order.production_no = None + order.planned_material_cost = 0 + order.actual_material_cost = 0 + order.status = "manufacturing" + + order.total_amount = await _apply_order_items(db_session, order, order_data) + await _issue_materials_for_order_creation(db_session, order, current_user) + await db_session.commit() + except (HTTPException, Exception): + await db_session.rollback() + raise + await db_session.refresh(order) + + customer_result = await db_session.execute(select(Customer).where(Customer.id == order.customer_id)) + updated_customer = customer_result.scalar_one_or_none() + customer_name = updated_customer.name if updated_customer else "未知客户" + return _build_sales_order_response(order, customer_name) + + +@router.patch("/{order_id}/status", response_model=SalesOrderResponse) +async def update_sales_order_status( + order_id: int, + payload: SalesOrderStatusUpdate, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + if payload.status not in VALID_ORDER_STATUSES: + raise HTTPException(status_code=400, detail="订单状态必须为 manufacturing、delivered、paid") + + order, customer = await _get_sales_order_with_customer(db_session, order_id) + # 只禁止从已交付状态改为非已收款状态 + if order.status == "delivered" and payload.status != "paid": + raise HTTPException(status_code=400, detail="已交付的销售订单只能修改为已收款状态") + + # 根据状态更新相应的日期字段 + from datetime import datetime + if payload.status == "delivered" and not order.actual_delivery_date: + order.actual_delivery_date = datetime.now() + elif payload.status == "paid" and not order.actual_payment_date: + order.actual_payment_date = datetime.now() + + order.status = payload.status + await db_session.commit() + await db_session.refresh(order) + return _build_sales_order_response(order, customer.name) + + +@router.delete("/{order_id}") +async def delete_sales_order( + order_id: int, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + order, customer = await _get_sales_order_with_customer(db_session, order_id) + if order.status == "delivered": + raise HTTPException(status_code=400, detail="已交付的销售订单禁止删除") + await _rollback_issued_materials(db_session, order, current_user) + await db_session.delete(order) + await db_session.commit() + return {"message": "销售订单已删除"} + + +class MaterialConsumptionItem(BaseModel): + material_id: int + quantity: float = Field(gt=0) + remark: Optional[str] = None + + +class MaterialConsumptionRequest(BaseModel): + items: List[MaterialConsumptionItem] = Field(min_length=1) + + +@router.post("/{order_id}/consume-materials") +async def consume_materials( + order_id: int, + request: MaterialConsumptionRequest, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + """记录销售订单的物料消耗""" + order, customer = await _get_sales_order_with_customer(db_session, order_id) + + default_warehouse = await _get_default_warehouse(db_session) + + # 计算总物料成本 + total_cost = Decimal("0") + + # 处理每个物料消耗项 + for item in request.items: + # 获取物料信息 + material = await db_session.get(Product, item.material_id) + if not material: + raise HTTPException(status_code=404, detail=f"物料 ID {item.material_id} 不存在") + if material.item_type != "material": + raise HTTPException(status_code=400, detail=f"只能消耗物料类型的产品: {material.name}") + + # 计算成本 + cost = Decimal(str(material.cost_price or 0)) * item.quantity + total_cost += cost + + # 更新物料库存(原子操作防并发) + upd_result = await db_session.execute( + update(Inventory) + .where(Inventory.product_id == material.id) + .where(Inventory.warehouse_id == default_warehouse.id) + .where(Inventory.quantity >= item.quantity) + .values(quantity=Inventory.quantity - item.quantity) + .returning(Inventory.quantity) + ) + after_qty = upd_result.scalar_one_or_none() + if after_qty is None: + raise HTTPException(status_code=400, detail=f"物料 {material.name} 库存不足") + after_qty = int(after_qty) + before_qty = after_qty + int(item.quantity) + + # 记录物料消耗 + movement = StockMovement( + product_id=material.id, + warehouse_id=default_warehouse.id, + quantity=item.quantity, + before_quantity=before_qty, + after_quantity=after_qty, + movement_type="consumption", + reference_type="sales_order", + reference_id=order.id, + unit_price=material.cost_price, + total_amount=cost, + operator_id=current_user.id, + remark=item.remark + ) + db_session.add(movement) + + # 更新订单的实际物料成本 + order.actual_material_cost = total_cost + + await db_session.commit() + await db_session.refresh(order) + + return { + "message": "物料消耗记录保存成功", + "total_cost": total_cost, + "order": _build_sales_order_response(order, customer.name) + } + + +@router.get("/{order_id}/production-plan", response_model=SalesOrderProductionPlanResponse) +async def get_sales_order_production_plan( + order_id: int, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + order, customer = await _get_sales_order_with_customer(db_session, order_id) + production_no = order.production_no or generate_order_no("WO") + plan_items, planned_material_cost = await _build_material_plan(db_session, order) + + return SalesOrderProductionPlanResponse( + sales_order_id=order.id, + order_no=order.order_no, + customer_name=customer.name, + production_no=production_no, + planned_material_cost=planned_material_cost, + items=plan_items, + ) + + +@router.post("/{order_id}/issue-materials", response_model=SalesOrderIssueResponse) +async def issue_sales_order_materials( + order_id: int, + payload: SalesOrderIssueRequest, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + order, _ = await _get_sales_order_with_customer(db_session, order_id) + if order.status == "delivered": + raise HTTPException(status_code=400, detail="已交付的销售订单禁止领料") + if order.production_status == "completed": + raise HTTPException(status_code=400, detail="该销售单已完成生产") + if order.production_status == "material_issued": + raise HTTPException(status_code=400, detail="该销售单已自动扣减过物料") + + warehouse_result = await db_session.execute( + select(Warehouse).where(Warehouse.id == payload.warehouse_id, Warehouse.is_active == True) + ) + warehouse = warehouse_result.scalar_one_or_none() + if not warehouse: + raise HTTPException(status_code=404, detail="仓库不存在") + + plan_items, planned_material_cost = await _build_material_plan(db_session, order) + if not plan_items: + raise HTTPException(status_code=400, detail="该销售单未配置BOM,无法领料") + + shortage_items = [item for item in plan_items if item.shortage_quantity > 0] + if shortage_items: + shortage_text = ",".join([f"{item.material_name} 缺 {item.shortage_quantity}" for item in shortage_items]) + raise HTTPException(status_code=400, detail=f"物料库存不足:{shortage_text}") + + production_no = payload.production_no or order.production_no or generate_order_no("WO") + + actual_material_cost = Decimal("0") + movement_count = 0 + for item in plan_items: + upd_result = await db_session.execute( + update(Inventory) + .where(Inventory.product_id == item.material_id) + .where(Inventory.warehouse_id == warehouse.id) + .where(Inventory.quantity >= item.required_quantity) + .values(quantity=Inventory.quantity - item.required_quantity) + .returning(Inventory.quantity) + ) + after_qty = upd_result.scalar_one_or_none() + if after_qty is None: + raise HTTPException(status_code=400, detail=f"{item.material_name} 在所选仓库库存不足") + after_qty = int(after_qty) + before_qty = after_qty + int(item.required_quantity) + total_amount = item.required_quantity * item.unit_cost + actual_material_cost += total_amount + + movement = StockMovement( + product_id=item.material_id, + warehouse_id=warehouse.id, + movement_type="issue_to_production", + quantity=item.required_quantity, + before_quantity=before_qty, + after_quantity=after_qty, + reference_type="sales_order", + reference_id=order.id, + reference_no=production_no, + unit_price=item.unit_cost, + total_amount=Decimal(str(total_amount)), + remark=payload.remark or f"销售单{order.order_no}按单生产领料", + operator_id=current_user.id + ) + db_session.add(movement) + movement_count += 1 + + order.production_no = production_no + order.production_status = "material_issued" + order.planned_material_cost = planned_material_cost + order.actual_material_cost = actual_material_cost + if order.status == "draft": + order.status = "manufacturing" + + await db_session.commit() + + cost_deviation = actual_material_cost - planned_material_cost + cost_deviation_rate = (cost_deviation / planned_material_cost) if planned_material_cost > Decimal("1e-9") else Decimal("0") + return SalesOrderIssueResponse( + sales_order_id=order.id, + order_no=order.order_no, + production_no=production_no, + movement_count=movement_count, + planned_material_cost=planned_material_cost, + actual_material_cost=actual_material_cost, + cost_deviation=cost_deviation, + cost_deviation_rate=cost_deviation_rate, + production_status=order.production_status, + ) diff --git a/src/api/inventory/schemas/__init__.py b/src/api/inventory/schemas/__init__.py new file mode 100644 index 0000000..d54ae74 --- /dev/null +++ b/src/api/inventory/schemas/__init__.py @@ -0,0 +1,82 @@ +from .product_schemas import ( + ProductCreate, + ProductResponse, + ProductMaterialItemUpdate, + ProductBOMUpdate, + ProductMaterialItemResponse, + ProductBOMResponse +) +from .supplier_schemas import SupplierCreate, SupplierResponse +from .customer_schemas import CustomerCreate, CustomerResponse +from .warehouse_schemas import WarehouseCreate, WarehouseResponse +from .inventory_schemas import InventoryResponse, InventoryCreate, InventoryUpdate +from .stock_movement_schemas import StockMovementCreate, StockMovementResponse +from .purchase_order_schemas import ( + PurchaseOrderCreate, + PurchaseOrderResponse, + PurchaseOrderItemCreate, + PurchaseOrderItemResponse, + PurchaseOrderDetailResponse, + PurchaseOrderReceiveItem, + PurchaseOrderReceiveRequest +) +from .sales_order_schemas import ( + SalesOrderCreate, + SalesOrderResponse, + SalesOrderItemCreate, + SalesOrderItemResponse, + SalesOrderDetailResponse, + ProductionMaterialPlanItemResponse, + SalesOrderProductionPlanResponse, + SalesOrderIssueRequest, + SalesOrderIssueResponse, + SalesOrderStatusUpdate +) +from .finance_schemas import ( + FinanceAllocationCreate, + FinanceTransactionCreate, + ReceiptCreate, + PaymentCreate, + FinanceAllocationResponse, + FinanceTransactionResponse, + FinanceSummaryResponse, + ReceivableItemResponse, + PayableItemResponse, + PartnerStatementItemResponse, + FinancePartnerStatementResponse, + PartnerProductStatementItemResponse, + FinancePartnerProductStatementResponse +) +from .material_schemas import ( + MaterialPriceHistoryCreate, + MaterialPriceHistoryResponse, + MaterialSupplierCreate, + MaterialSupplierResponse, + MaterialPriceTrendResponse +) + +from .common_schemas import PaginatedResponse + +__all__ = [ + "PaginatedResponse", + "ProductCreate", "ProductResponse", "ProductMaterialItemUpdate", "ProductBOMUpdate", + "ProductMaterialItemResponse", "ProductBOMResponse", + "SupplierCreate", "SupplierResponse", + "CustomerCreate", "CustomerResponse", + "WarehouseCreate", "WarehouseResponse", + "InventoryResponse", "InventoryCreate", "InventoryUpdate", + "StockMovementCreate", "StockMovementResponse", + "PurchaseOrderCreate", "PurchaseOrderResponse", "PurchaseOrderItemCreate", + "PurchaseOrderItemResponse", "PurchaseOrderDetailResponse", "PurchaseOrderReceiveItem", "PurchaseOrderReceiveRequest", + "SalesOrderCreate", "SalesOrderResponse", "SalesOrderItemCreate", "SalesOrderItemResponse", "SalesOrderDetailResponse", + "ProductionMaterialPlanItemResponse", "SalesOrderProductionPlanResponse", + "SalesOrderIssueRequest", "SalesOrderIssueResponse", "SalesOrderStatusUpdate", + "FinanceAllocationCreate", "FinanceTransactionCreate", + "ReceiptCreate", "PaymentCreate", + "FinanceAllocationResponse", "FinanceTransactionResponse", + "FinanceSummaryResponse", "ReceivableItemResponse", "PayableItemResponse", + "PartnerStatementItemResponse", "FinancePartnerStatementResponse", + "PartnerProductStatementItemResponse", "FinancePartnerProductStatementResponse", + "MaterialPriceHistoryCreate", "MaterialPriceHistoryResponse", + "MaterialSupplierCreate", "MaterialSupplierResponse", "MaterialPriceTrendResponse", +] diff --git a/src/api/inventory/schemas/common_schemas.py b/src/api/inventory/schemas/common_schemas.py new file mode 100644 index 0000000..39ef9ea --- /dev/null +++ b/src/api/inventory/schemas/common_schemas.py @@ -0,0 +1,11 @@ +from typing import TypeVar, Generic, List +from pydantic import BaseModel + +T = TypeVar("T") + + +class PaginatedResponse(BaseModel, Generic[T]): + items: List[T] + total: int + skip: int + limit: int diff --git a/src/api/inventory/schemas/product_schemas.py b/src/api/inventory/schemas/product_schemas.py new file mode 100644 index 0000000..be1bdf0 --- /dev/null +++ b/src/api/inventory/schemas/product_schemas.py @@ -0,0 +1,63 @@ +from pydantic import BaseModel +from typing import Optional, List +from datetime import datetime +from decimal import Decimal + + +class ProductCreate(BaseModel): + sku: str + name: str + description: Optional[str] = None + category: Optional[str] = None + unit: str = "件" + item_type: str = "finished" + cost_price: Decimal = Decimal("0") + sale_price: Decimal = Decimal("0") + min_stock: int = 0 + max_stock: int = 1000 + + +class ProductResponse(BaseModel): + id: int + sku: str + name: str + description: Optional[str] + category: Optional[str] + unit: str + item_type: str + cost_price: Decimal + sale_price: Decimal + min_stock: int + max_stock: int + material_cost: Decimal = Decimal("0") + is_active: bool + created_at: datetime + + class Config: + from_attributes = True + + +class ProductMaterialItemUpdate(BaseModel): + material_id: int + quantity: Decimal + loss_rate: Decimal = Decimal("0") + + +class ProductBOMUpdate(BaseModel): + items: List[ProductMaterialItemUpdate] + + +class ProductMaterialItemResponse(BaseModel): + material_id: int + material_sku: str + material_name: str + quantity: Decimal + unit_cost: Decimal + line_cost: Decimal + + +class ProductBOMResponse(BaseModel): + product_id: int + product_name: str + total_material_cost: Decimal + items: List[ProductMaterialItemResponse] diff --git a/src/api/inventory/stock_movement_routes.py b/src/api/inventory/stock_movement_routes.py new file mode 100644 index 0000000..d874173 --- /dev/null +++ b/src/api/inventory/stock_movement_routes.py @@ -0,0 +1,209 @@ +""" +库存变动路由模块 + +提供库存变动的管理功能,包括: +- 创建库存变动记录(入库、出库、调整) +- 库存变动历史查询(支持分页、产品筛选、变动类型筛选) +- 自动更新库存数量 +- 库存不足校验(出库时) + +路由前缀: /api/stock-movements +""" +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, update, func +from typing import Optional, List + +from database.database import get_db_session +from services.auth_service import get_current_active_user +from models.database import User, Product, Warehouse, Inventory, StockMovement +from .schemas import StockMovementCreate, StockMovementResponse, PaginatedResponse +from .utils import generate_order_no + +router = APIRouter(prefix="/stock-movements", tags=["库存变动"]) + +INBOUND_TYPES = { + "in", "purchase_in", "return_from_production", "outsource_return", "finish_in" +} +OUTBOUND_TYPES = { + "out", "issue_to_production", "outsource_send", "shipment_out", "scrap_out" +} +ADJUST_TYPES = {"adjust"} +SUPPORTED_MOVEMENT_TYPES = INBOUND_TYPES | OUTBOUND_TYPES | ADJUST_TYPES + + +@router.post("", response_model=StockMovementResponse, status_code=201) +async def create_stock_movement( + movement_data: StockMovementCreate, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + if movement_data.movement_type not in SUPPORTED_MOVEMENT_TYPES: + raise HTTPException(status_code=400, detail="无效的变动类型") + if movement_data.quantity <= 0: + raise HTTPException(status_code=400, detail="数量必须大于0") + + warehouse_result = await db_session.execute( + select(Warehouse) + .where(Warehouse.id == movement_data.warehouse_id) + .where(Warehouse.is_active == True) + ) + warehouse = warehouse_result.scalar_one_or_none() + if not warehouse: + raise HTTPException(status_code=404, detail="仓库不存在") + + product_result = await db_session.execute( + select(Product) + .where(Product.id == movement_data.product_id) + .where(Product.is_active == True) + ) + product = product_result.scalar_one_or_none() + if not product: + sku_candidate = movement_data.product_sku or str(movement_data.product_id) + product_by_sku_result = await db_session.execute( + select(Product) + .where(Product.sku == sku_candidate) + .where(Product.is_active == True) + ) + product = product_by_sku_result.scalar_one_or_none() + if not product: + raise HTTPException(status_code=404, detail="产品不存在,请选择系统中的产品") + if product.item_type != "material": + raise HTTPException(status_code=400, detail="库存仅管理物料,该条目不是物料") + + resolved_product_id = product.id + + if movement_data.movement_type in INBOUND_TYPES: + upd_result = await db_session.execute( + update(Inventory) + .where(Inventory.product_id == resolved_product_id) + .where(Inventory.warehouse_id == movement_data.warehouse_id) + .values(quantity=Inventory.quantity + movement_data.quantity) + .returning(Inventory.quantity) + ) + after_qty = upd_result.scalar_one_or_none() + if after_qty is None: + inventory = Inventory( + product_id=resolved_product_id, + warehouse_id=movement_data.warehouse_id, + quantity=movement_data.quantity, + ) + db_session.add(inventory) + await db_session.flush() + before_qty = 0 + after_qty = movement_data.quantity + else: + after_qty = int(after_qty) + before_qty = after_qty - movement_data.quantity + + elif movement_data.movement_type in OUTBOUND_TYPES: + upd_result = await db_session.execute( + update(Inventory) + .where(Inventory.product_id == resolved_product_id) + .where(Inventory.warehouse_id == movement_data.warehouse_id) + .where(Inventory.quantity >= movement_data.quantity) + .values(quantity=Inventory.quantity - movement_data.quantity) + .returning(Inventory.quantity) + ) + after_qty = upd_result.scalar_one_or_none() + if after_qty is None: + raise HTTPException(status_code=400, detail="库存不足") + after_qty = int(after_qty) + before_qty = after_qty + movement_data.quantity + + else: + result = await db_session.execute( + select(Inventory) + .where(Inventory.product_id == resolved_product_id) + .where(Inventory.warehouse_id == movement_data.warehouse_id) + .with_for_update() + ) + inventory = result.scalar_one_or_none() + if not inventory: + inventory = Inventory( + product_id=resolved_product_id, + warehouse_id=movement_data.warehouse_id, + quantity=0, + ) + db_session.add(inventory) + await db_session.flush() + before_qty = 0 + else: + before_qty = int(inventory.quantity) + inventory.quantity = movement_data.quantity + after_qty = movement_data.quantity + + movement = StockMovement( + product_id=resolved_product_id, + warehouse_id=movement_data.warehouse_id, + movement_type=movement_data.movement_type, + quantity=movement_data.quantity, + before_quantity=before_qty, + after_quantity=after_qty, + reference_no=generate_order_no("SM"), + unit_price=movement_data.unit_price, + total_amount=movement_data.unit_price * movement_data.quantity if movement_data.unit_price else None, + remark=movement_data.remark, + operator_id=current_user.id + ) + db_session.add(movement) + await db_session.commit() + + return StockMovementResponse( + id=movement.id, + product_id=product.id, + product_sku=product.sku, + product_name=product.name, + movement_type=movement.movement_type, + quantity=movement.quantity, + before_quantity=movement.before_quantity, + after_quantity=movement.after_quantity, + reference_no=movement.reference_no, + remark=movement.remark, + created_at=movement.created_at + ) + + +@router.get("", response_model=PaginatedResponse[StockMovementResponse]) +async def list_stock_movements( + product_id: Optional[int] = None, + movement_type: Optional[str] = None, + skip: int = Query(0, ge=0), + limit: int = Query(20, ge=1, le=100), + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + base_query = ( + select(StockMovement, Product) + .join(Product, StockMovement.product_id == Product.id) + .order_by(StockMovement.created_at.desc()) + ) + + if product_id: + base_query = base_query.where(StockMovement.product_id == product_id) + if movement_type: + base_query = base_query.where(StockMovement.movement_type == movement_type) + + count_query = select(func.count()).select_from(base_query.subquery()) + total = await db_session.scalar(count_query) or 0 + + query = base_query.offset(skip).limit(limit) + result = await db_session.execute(query) + + movements = [] + for movement, product in result.all(): + movements.append(StockMovementResponse( + id=movement.id, + product_id=product.id, + product_sku=product.sku, + product_name=product.name, + movement_type=movement.movement_type, + quantity=movement.quantity, + before_quantity=movement.before_quantity, + after_quantity=movement.after_quantity, + reference_no=movement.reference_no, + remark=movement.remark, + created_at=movement.created_at + )) + + return PaginatedResponse(items=movements, total=total, skip=skip, limit=limit) diff --git a/src/api/inventory/utils.py b/src/api/inventory/utils.py new file mode 100644 index 0000000..a451f7a --- /dev/null +++ b/src/api/inventory/utils.py @@ -0,0 +1,22 @@ +""" +库存管理工具函数模块 + +提供进销存系统通用的工具函数,包括: +- 订单编号生成器(采购订单、销售订单、库存变动等) +""" +from datetime import datetime, timezone +import secrets + + +def generate_order_no(prefix: str) -> str: + """生成订单编号 + + Args: + prefix: 订单类型前缀,如 PO(采购订单)、SO(销售订单)、SM(库存变动) + + Returns: + 格式为 {prefix}{YYYYMMDDHHMMSS}{8位随机字符} 的订单编号 + """ + date_str = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S") + random_str = secrets.token_hex(4).upper() + return f"{prefix}{date_str}{random_str}"