增加财务模块

This commit is contained in:
cjw
2026-03-15 15:47:43 +08:00
parent 4453c9eb9b
commit 48853a988d
7 changed files with 614 additions and 14 deletions
+2
View File
@@ -24,6 +24,7 @@ from .stock_movement_routes import router as stock_movement_router
from .purchase_order_routes import router as purchase_order_router
from .sales_order_routes import router as sales_order_router
from .dashboard_routes import router as dashboard_router
from .finance_routes import router as finance_router
inventory_router = APIRouter(prefix="/api", tags=["进销存"])
@@ -36,5 +37,6 @@ inventory_router.include_router(stock_movement_router)
inventory_router.include_router(purchase_order_router)
inventory_router.include_router(sales_order_router)
inventory_router.include_router(dashboard_router)
inventory_router.include_router(finance_router)
__all__ = ["inventory_router"]
+361
View File
@@ -0,0 +1,361 @@
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
from datetime import datetime
from database.database import get_db_session
from services.auth_service import get_current_active_user
from models.database import (
User,
Customer,
Supplier,
SalesOrder,
PurchaseOrder,
FinanceTransaction,
FinanceAllocation,
)
from .schemas import (
ReceiptCreate,
PaymentCreate,
FinanceTransactionResponse,
FinanceSummaryResponse,
ReceivableItemResponse,
PayableItemResponse,
)
from .utils import generate_order_no
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="核销总额不能大于单据金额")
@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=List[FinanceTransactionResponse])
async def list_transactions(
txn_type: Optional[str] = None,
status: Optional[str] = "confirmed",
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),
):
query = (
select(FinanceTransaction)
.options(selectinload(FinanceTransaction.allocations))
.order_by(FinanceTransaction.created_at.desc())
)
if txn_type:
query = query.where(FinanceTransaction.txn_type == txn_type)
if status:
query = query.where(FinanceTransaction.status == status)
query = query.offset(skip).limit(limit)
result = await db_session.execute(query)
rows = result.scalars().all()
return [_build_transaction_response(item) for item in rows]
@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()
return {"message": "单据已作废"}
@router.get("/receivables", response_model=List[ReceivableItemResponse])
async def list_receivables(
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),
):
result = await db_session.execute(
select(SalesOrder, Customer)
.join(Customer, SalesOrder.customer_id == Customer.id)
.where((SalesOrder.total_amount - SalesOrder.received_amount) > 0)
.order_by(SalesOrder.created_at.desc())
.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(
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),
):
result = await db_session.execute(
select(PurchaseOrder, Supplier)
.join(Supplier, PurchaseOrder.supplier_id == Supplier.id)
.where((PurchaseOrder.total_amount - PurchaseOrder.paid_amount) > 0)
.order_by(PurchaseOrder.created_at.desc())
.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(
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
return FinanceSummaryResponse(
receivable_total=round(float(receivable_total), 2),
payable_total=round(float(payable_total), 2),
monthly_receipt_total=round(float(monthly_receipt_total), 2),
monthly_payment_total=round(float(monthly_payment_total), 2),
overdue_receivable_count=0,
overdue_payable_count=0,
)
+15
View File
@@ -14,6 +14,17 @@ from .sales_order_schemas import (
SalesOrderResponse,
SalesOrderItemCreate
)
from .finance_schemas import (
FinanceAllocationCreate,
FinanceTransactionCreate,
ReceiptCreate,
PaymentCreate,
FinanceAllocationResponse,
FinanceTransactionResponse,
FinanceSummaryResponse,
ReceivableItemResponse,
PayableItemResponse
)
__all__ = [
"ProductCreate", "ProductResponse",
@@ -24,4 +35,8 @@ __all__ = [
"StockMovementCreate", "StockMovementResponse",
"PurchaseOrderCreate", "PurchaseOrderResponse", "PurchaseOrderItemCreate",
"SalesOrderCreate", "SalesOrderResponse", "SalesOrderItemCreate",
"FinanceAllocationCreate", "FinanceTransactionCreate",
"ReceiptCreate", "PaymentCreate",
"FinanceAllocationResponse", "FinanceTransactionResponse",
"FinanceSummaryResponse", "ReceivableItemResponse", "PayableItemResponse",
]
@@ -0,0 +1,94 @@
from pydantic import BaseModel, Field
from typing import Optional, List, Literal
from datetime import datetime
TxnType = Literal["receipt", "payment"]
PartnerType = Literal["customer", "supplier"]
OrderType = Literal["sales", "purchase"]
TxnStatus = Literal["confirmed", "voided"]
class FinanceAllocationCreate(BaseModel):
order_type: OrderType
order_id: int
allocated_amount: float = Field(gt=0)
class FinanceTransactionCreate(BaseModel):
amount: float = Field(gt=0)
txn_date: Optional[datetime] = None
method: str = "bank"
account_name: Optional[str] = None
remark: Optional[str] = None
allocations: List[FinanceAllocationCreate]
class ReceiptCreate(FinanceTransactionCreate):
customer_id: int
class PaymentCreate(FinanceTransactionCreate):
supplier_id: int
class FinanceAllocationResponse(BaseModel):
id: int
order_type: str
order_id: int
allocated_amount: float
class Config:
from_attributes = True
class FinanceTransactionResponse(BaseModel):
id: int
txn_no: str
txn_type: TxnType
partner_type: PartnerType
partner_id: int
amount: float
txn_date: datetime
method: str
account_name: Optional[str]
status: TxnStatus
remark: Optional[str]
created_at: datetime
allocations: List[FinanceAllocationResponse] = []
class Config:
from_attributes = True
class FinanceSummaryResponse(BaseModel):
receivable_total: float
payable_total: float
monthly_receipt_total: float
monthly_payment_total: float
overdue_receivable_count: int = 0
overdue_payable_count: int = 0
class ReceivableItemResponse(BaseModel):
order_id: int
order_no: str
customer_id: int
customer_name: str
order_date: datetime
total_amount: float
received_amount: float
receivable_amount: float
status: str
class PayableItemResponse(BaseModel):
order_id: int
order_no: str
supplier_id: int
supplier_name: str
order_date: datetime
total_amount: float
paid_amount: float
payable_amount: float
status: str