This commit is contained in:
2026-03-15 22:04:28 +08:00
parent 48853a988d
commit 66f2bd2daa
6 changed files with 721 additions and 35 deletions
+381 -7
View File
@@ -2,7 +2,7 @@ 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 typing import Optional, List, Dict, Tuple
from datetime import datetime
from database.database import get_db_session
@@ -11,8 +11,11 @@ from models.database import (
User,
Customer,
Supplier,
Product,
SalesOrder,
SalesOrderItem,
PurchaseOrder,
PurchaseOrderItem,
FinanceTransaction,
FinanceAllocation,
)
@@ -23,6 +26,10 @@ from .schemas import (
FinanceSummaryResponse,
ReceivableItemResponse,
PayableItemResponse,
FinancePartnerStatementResponse,
PartnerStatementItemResponse,
FinancePartnerProductStatementResponse,
PartnerProductStatementItemResponse,
)
from .utils import generate_order_no
@@ -62,6 +69,31 @@ def _validate_allocation_total(transaction_amount: float, allocation_amounts: Li
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,
@@ -198,6 +230,8 @@ async def create_payment(
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),
@@ -212,6 +246,9 @@ async def list_transactions(
query = query.where(FinanceTransaction.txn_type == txn_type)
if status:
query = query.where(FinanceTransaction.status == status)
if year is not None or quarter is not None:
_, _, _, period_start, period_end = _resolve_period_scope(year, quarter)
query = query.where(FinanceTransaction.txn_date >= period_start).where(FinanceTransaction.txn_date < period_end)
query = query.offset(skip).limit(limit)
result = await db_session.execute(query)
rows = result.scalars().all()
@@ -254,19 +291,23 @@ async def void_transaction(
@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),
):
result = await db_session.execute(
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())
.offset(skip)
.limit(limit)
)
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)
@@ -288,19 +329,23 @@ async def list_receivables(
@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),
):
result = await db_session.execute(
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())
.offset(skip)
.limit(limit)
)
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)
@@ -322,6 +367,8 @@ async def list_payables(
@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),
):
@@ -350,12 +397,339 @@ async def get_finance_summary(
.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=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),
selected_year=selected_year,
selected_quarter=selected_quarter,
period_label=period_label,
period_receipt_total=round(float(period_receipt_total), 2),
period_payment_total=round(float(period_payment_total), 2),
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 = float(order.total_amount or 0)
settled_amount = float(order.received_amount or 0)
outstanding = max(total_amount - settled_amount, 0.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"] += float(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 = float(order.total_amount or 0)
settled_amount = float(order.paid_amount or 0)
outstanding = max(total_amount - settled_amount, 0.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"] += float(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=round(float(item["order_total"]), 2),
settled_total=round(float(item["settled_total"]), 2),
transaction_total=round(float(item["transaction_total"]), 2),
outstanding_total=round(float(item["outstanding_total"]), 2),
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=round(float(sum(item.order_total for item in items)), 2),
settled_total=round(float(sum(item.settled_total for item in items)), 2),
transaction_total=round(float(sum(item.transaction_total for item in items)), 2),
outstanding_total=round(float(sum(item.outstanding_total for item in items)), 2),
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 = float(item.amount or 0)
order_total = float(order.total_amount or 0)
order_settled = max(float(order.received_amount or 0), 0.0)
ratio = (item_amount / order_total) if order_total > 1e-9 else 0.0
item_settled = min(item_amount, order_settled * ratio)
item_outstanding = max(item_amount - item_settled, 0.0)
stat["order_ids"].add(order.id)
stat["order_quantity"] += float(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 = float(item.amount or 0)
order_total = float(order.total_amount or 0)
order_settled = max(float(order.paid_amount or 0), 0.0)
ratio = (item_amount / order_total) if order_total > 1e-9 else 0.0
item_settled = min(item_amount, order_settled * ratio)
item_outstanding = max(item_amount - item_settled, 0.0)
stat["order_ids"].add(order.id)
stat["order_quantity"] += float(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=round(float(item["order_quantity"]), 2),
order_amount=round(float(item["order_amount"]), 2),
settled_amount=round(float(item["settled_amount"]), 2),
outstanding_amount=round(float(item["outstanding_amount"]), 2),
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=round(float(sum(item.order_amount for item in items)), 2),
settled_amount_total=round(float(sum(item.settled_amount for item in items)), 2),
outstanding_amount_total=round(float(sum(item.outstanding_amount for item in items)), 2),
items=items,
)