x
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -23,7 +23,11 @@ from .finance_schemas import (
|
||||
FinanceTransactionResponse,
|
||||
FinanceSummaryResponse,
|
||||
ReceivableItemResponse,
|
||||
PayableItemResponse
|
||||
PayableItemResponse,
|
||||
PartnerStatementItemResponse,
|
||||
FinancePartnerStatementResponse,
|
||||
PartnerProductStatementItemResponse,
|
||||
FinancePartnerProductStatementResponse
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -39,4 +43,6 @@ __all__ = [
|
||||
"ReceiptCreate", "PaymentCreate",
|
||||
"FinanceAllocationResponse", "FinanceTransactionResponse",
|
||||
"FinanceSummaryResponse", "ReceivableItemResponse", "PayableItemResponse",
|
||||
"PartnerStatementItemResponse", "FinancePartnerStatementResponse",
|
||||
"PartnerProductStatementItemResponse", "FinancePartnerProductStatementResponse",
|
||||
]
|
||||
|
||||
@@ -66,6 +66,11 @@ class FinanceSummaryResponse(BaseModel):
|
||||
payable_total: float
|
||||
monthly_receipt_total: float
|
||||
monthly_payment_total: float
|
||||
selected_year: int
|
||||
selected_quarter: Optional[int] = None
|
||||
period_label: str
|
||||
period_receipt_total: float = 0
|
||||
period_payment_total: float = 0
|
||||
overdue_receivable_count: int = 0
|
||||
overdue_payable_count: int = 0
|
||||
|
||||
@@ -92,3 +97,55 @@ class PayableItemResponse(BaseModel):
|
||||
paid_amount: float
|
||||
payable_amount: float
|
||||
status: str
|
||||
|
||||
|
||||
class PartnerStatementItemResponse(BaseModel):
|
||||
partner_id: int
|
||||
partner_name: str
|
||||
order_count: int
|
||||
transaction_count: int
|
||||
order_total: float
|
||||
settled_total: float
|
||||
transaction_total: float
|
||||
outstanding_total: float
|
||||
period_year: int
|
||||
period_quarter: Optional[int] = None
|
||||
|
||||
|
||||
class FinancePartnerStatementResponse(BaseModel):
|
||||
partner_type: PartnerType
|
||||
year: int
|
||||
quarter: Optional[int] = None
|
||||
period_label: str
|
||||
order_total: float
|
||||
settled_total: float
|
||||
transaction_total: float
|
||||
outstanding_total: float
|
||||
items: List[PartnerStatementItemResponse] = []
|
||||
|
||||
|
||||
class PartnerProductStatementItemResponse(BaseModel):
|
||||
partner_id: int
|
||||
partner_name: str
|
||||
product_id: int
|
||||
product_sku: Optional[str] = None
|
||||
product_name: str
|
||||
order_count: int
|
||||
order_quantity: float
|
||||
order_amount: float
|
||||
settled_amount: float
|
||||
outstanding_amount: float
|
||||
period_year: int
|
||||
period_quarter: Optional[int] = None
|
||||
|
||||
|
||||
class FinancePartnerProductStatementResponse(BaseModel):
|
||||
partner_type: PartnerType
|
||||
year: int
|
||||
quarter: Optional[int] = None
|
||||
period_label: str
|
||||
partner_id: Optional[int] = None
|
||||
order_amount_total: float
|
||||
settled_amount_total: float
|
||||
outstanding_amount_total: float
|
||||
items: List[PartnerProductStatementItemResponse] = []
|
||||
|
||||
@@ -5,6 +5,7 @@ from datetime import datetime
|
||||
|
||||
class StockMovementCreate(BaseModel):
|
||||
product_id: int
|
||||
product_sku: Optional[str] = None
|
||||
warehouse_id: int
|
||||
movement_type: str
|
||||
quantity: int
|
||||
@@ -14,6 +15,8 @@ class StockMovementCreate(BaseModel):
|
||||
|
||||
class StockMovementResponse(BaseModel):
|
||||
id: int
|
||||
product_id: int
|
||||
product_sku: Optional[str]
|
||||
product_name: str
|
||||
movement_type: str
|
||||
quantity: int
|
||||
|
||||
@@ -31,10 +31,40 @@ async def create_stock_movement(
|
||||
):
|
||||
if movement_data.movement_type not in ["in", "out", "adjust"]:
|
||||
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="产品不存在,请选择系统中的产品")
|
||||
|
||||
resolved_product_id = product.id
|
||||
|
||||
result = await db_session.execute(
|
||||
select(Inventory)
|
||||
.where(Inventory.product_id == movement_data.product_id)
|
||||
.where(Inventory.product_id == resolved_product_id)
|
||||
.where(Inventory.warehouse_id == movement_data.warehouse_id)
|
||||
)
|
||||
inventory = result.scalar_one_or_none()
|
||||
@@ -43,7 +73,7 @@ async def create_stock_movement(
|
||||
if movement_data.movement_type == "out":
|
||||
raise HTTPException(status_code=400, detail="库存不足")
|
||||
inventory = Inventory(
|
||||
product_id=movement_data.product_id,
|
||||
product_id=resolved_product_id,
|
||||
warehouse_id=movement_data.warehouse_id,
|
||||
quantity=0
|
||||
)
|
||||
@@ -64,7 +94,7 @@ async def create_stock_movement(
|
||||
after_qty = inventory.quantity
|
||||
|
||||
movement = StockMovement(
|
||||
product_id=movement_data.product_id,
|
||||
product_id=resolved_product_id,
|
||||
warehouse_id=movement_data.warehouse_id,
|
||||
movement_type=movement_data.movement_type,
|
||||
quantity=movement_data.quantity,
|
||||
@@ -78,12 +108,11 @@ async def create_stock_movement(
|
||||
)
|
||||
db_session.add(movement)
|
||||
await db_session.commit()
|
||||
|
||||
product = await db_session.execute(select(Product).where(Product.id == movement_data.product_id))
|
||||
product = product.scalar_one()
|
||||
|
||||
|
||||
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,
|
||||
@@ -122,6 +151,8 @@ async def list_stock_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,
|
||||
|
||||
+235
-20
@@ -1474,9 +1474,17 @@ const InventoryView = {
|
||||
activeTab: 'dashboard',
|
||||
dashboard: null,
|
||||
financeSummary: null,
|
||||
financePeriod: {
|
||||
year: new Date().getFullYear(),
|
||||
quarter: ''
|
||||
},
|
||||
financeTransactions: [],
|
||||
receivables: [],
|
||||
payables: [],
|
||||
customerFinanceStatement: [],
|
||||
supplierFinanceStatement: [],
|
||||
customerProductStatement: [],
|
||||
supplierProductStatement: [],
|
||||
products: [],
|
||||
suppliers: [],
|
||||
customers: [],
|
||||
@@ -1512,6 +1520,17 @@ const InventoryView = {
|
||||
}
|
||||
};
|
||||
|
||||
const loadWarehouses = async () => {
|
||||
state.loading = true;
|
||||
try {
|
||||
state.warehouses = await apiRequest('/api/warehouses');
|
||||
} catch (e) {
|
||||
handleApiError(e, '加载仓库');
|
||||
} finally {
|
||||
state.loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadSuppliers = async () => {
|
||||
state.loading = true;
|
||||
try {
|
||||
@@ -1559,16 +1578,39 @@ const InventoryView = {
|
||||
const loadFinance = async () => {
|
||||
state.loading = true;
|
||||
try {
|
||||
const [summary, transactions, receivables, payables] = await Promise.all([
|
||||
apiRequest('/api/finance/summary'),
|
||||
apiRequest('/api/finance/transactions?status=confirmed&limit=20'),
|
||||
apiRequest('/api/finance/receivables?limit=20'),
|
||||
apiRequest('/api/finance/payables?limit=20')
|
||||
const selectedYear = Number(state.financePeriod.year) || new Date().getFullYear();
|
||||
const selectedQuarter = state.financePeriod.quarter ? Number(state.financePeriod.quarter) : null;
|
||||
const periodQuery = selectedQuarter
|
||||
? `year=${selectedYear}&quarter=${selectedQuarter}`
|
||||
: `year=${selectedYear}`;
|
||||
|
||||
const [
|
||||
summary,
|
||||
transactions,
|
||||
receivables,
|
||||
payables,
|
||||
customerStatement,
|
||||
supplierStatement,
|
||||
customerProductStatement,
|
||||
supplierProductStatement
|
||||
] = await Promise.all([
|
||||
apiRequest(`/api/finance/summary?${periodQuery}`),
|
||||
apiRequest(`/api/finance/transactions?status=confirmed&limit=20&${periodQuery}`),
|
||||
apiRequest(`/api/finance/receivables?limit=20&${periodQuery}`),
|
||||
apiRequest(`/api/finance/payables?limit=20&${periodQuery}`),
|
||||
apiRequest(`/api/finance/partner-statement/customer?${periodQuery}`),
|
||||
apiRequest(`/api/finance/partner-statement/supplier?${periodQuery}`),
|
||||
apiRequest(`/api/finance/partner-product-statement/customer?${periodQuery}`),
|
||||
apiRequest(`/api/finance/partner-product-statement/supplier?${periodQuery}`)
|
||||
]);
|
||||
state.financeSummary = summary;
|
||||
state.financeTransactions = transactions;
|
||||
state.receivables = receivables;
|
||||
state.payables = payables;
|
||||
state.customerFinanceStatement = customerStatement.items || [];
|
||||
state.supplierFinanceStatement = supplierStatement.items || [];
|
||||
state.customerProductStatement = customerProductStatement.items || [];
|
||||
state.supplierProductStatement = supplierProductStatement.items || [];
|
||||
} catch (e) {
|
||||
handleApiError(e, '加载财务数据');
|
||||
} finally {
|
||||
@@ -1576,6 +1618,12 @@ const InventoryView = {
|
||||
}
|
||||
};
|
||||
|
||||
const refreshFinanceByPeriod = () => {
|
||||
if (state.activeTab === 'finance') {
|
||||
loadFinance();
|
||||
}
|
||||
};
|
||||
|
||||
const switchTab = (tab) => {
|
||||
state.activeTab = tab;
|
||||
switch (tab) {
|
||||
@@ -1589,13 +1637,26 @@ const InventoryView = {
|
||||
}
|
||||
};
|
||||
|
||||
const openModal = (type, item = null) => {
|
||||
const openModal = async (type, item = null) => {
|
||||
state.modalType = type;
|
||||
state.editingItem = item;
|
||||
if (item) {
|
||||
state.form = { ...item };
|
||||
} else {
|
||||
state.form = {};
|
||||
if (type === 'stockIn' || type === 'stockOut') {
|
||||
if (!state.products.length) {
|
||||
await loadProducts();
|
||||
}
|
||||
if (!state.warehouses.length) {
|
||||
await loadWarehouses();
|
||||
}
|
||||
state.form = {
|
||||
product_id: state.products[0]?.id || null,
|
||||
warehouse_id: state.warehouses.find(w => w.is_default)?.id || state.warehouses[0]?.id || null,
|
||||
quantity: 1
|
||||
};
|
||||
}
|
||||
}
|
||||
state.showModal = true;
|
||||
};
|
||||
@@ -1759,7 +1820,8 @@ const InventoryView = {
|
||||
saveCustomer,
|
||||
deleteCustomer,
|
||||
stockIn,
|
||||
stockOut
|
||||
stockOut,
|
||||
refreshFinanceByPeriod
|
||||
};
|
||||
},
|
||||
template: `
|
||||
@@ -1967,6 +2029,27 @@ const InventoryView = {
|
||||
</div>
|
||||
|
||||
<div v-else-if="state.activeTab === 'finance'">
|
||||
<div class="table-container" style="margin-bottom: 16px;">
|
||||
<div style="display:flex; gap:12px; align-items:center; flex-wrap:wrap;">
|
||||
<div>
|
||||
<label style="margin-right:8px;">年份</label>
|
||||
<input v-model.number="state.financePeriod.year" type="number" min="2000" max="2100" class="form-input" style="width:120px; display:inline-block;" />
|
||||
</div>
|
||||
<div>
|
||||
<label style="margin-right:8px;">季度</label>
|
||||
<select v-model="state.financePeriod.quarter" class="form-input" style="width:140px; display:inline-block;">
|
||||
<option value="">全年</option>
|
||||
<option value="1">Q1</option>
|
||||
<option value="2">Q2</option>
|
||||
<option value="3">Q3</option>
|
||||
<option value="4">Q4</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-primary" @click="refreshFinanceByPeriod">刷新统计</button>
|
||||
<span style="color:var(--text-secondary);">统计周期:{{ state.financeSummary?.period_label || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">🧾</div>
|
||||
@@ -1985,19 +2068,135 @@ const InventoryView = {
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">💵</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ formatCurrency(state.financeSummary?.monthly_receipt_total || 0) }}</div>
|
||||
<div class="stat-label">本月收款</div>
|
||||
<div class="stat-value">{{ formatCurrency(state.financeSummary?.period_receipt_total || 0) }}</div>
|
||||
<div class="stat-label">周期收款</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">🏦</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ formatCurrency(state.financeSummary?.monthly_payment_total || 0) }}</div>
|
||||
<div class="stat-label">本月付款</div>
|
||||
<div class="stat-value">{{ formatCurrency(state.financeSummary?.period_payment_total || 0) }}</div>
|
||||
<div class="stat-label">周期付款</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-container" style="margin-top: 16px;">
|
||||
<h3 style="margin-bottom: 12px;">客户账款(周期)</h3>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>客户</th>
|
||||
<th>订单数</th>
|
||||
<th>流水数</th>
|
||||
<th>订单金额</th>
|
||||
<th>订单已收</th>
|
||||
<th>实收流水</th>
|
||||
<th>应收余额</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in state.customerFinanceStatement" :key="'customer-' + item.partner_id">
|
||||
<td>{{ item.partner_name }}</td>
|
||||
<td>{{ item.order_count }}</td>
|
||||
<td>{{ item.transaction_count }}</td>
|
||||
<td>{{ formatCurrency(item.order_total) }}</td>
|
||||
<td>{{ formatCurrency(item.settled_total) }}</td>
|
||||
<td>{{ formatCurrency(item.transaction_total) }}</td>
|
||||
<td>{{ formatCurrency(item.outstanding_total) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="table-container" style="margin-top: 16px;">
|
||||
<h3 style="margin-bottom: 12px;">供应商账款(周期)</h3>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>供应商</th>
|
||||
<th>订单数</th>
|
||||
<th>流水数</th>
|
||||
<th>订单金额</th>
|
||||
<th>订单已付</th>
|
||||
<th>实付流水</th>
|
||||
<th>应付余额</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in state.supplierFinanceStatement" :key="'supplier-' + item.partner_id">
|
||||
<td>{{ item.partner_name }}</td>
|
||||
<td>{{ item.order_count }}</td>
|
||||
<td>{{ item.transaction_count }}</td>
|
||||
<td>{{ formatCurrency(item.order_total) }}</td>
|
||||
<td>{{ formatCurrency(item.settled_total) }}</td>
|
||||
<td>{{ formatCurrency(item.transaction_total) }}</td>
|
||||
<td>{{ formatCurrency(item.outstanding_total) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="table-container" style="margin-top: 16px;">
|
||||
<h3 style="margin-bottom: 12px;">客户-商品追溯(周期)</h3>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>客户</th>
|
||||
<th>SKU</th>
|
||||
<th>商品</th>
|
||||
<th>订单数</th>
|
||||
<th>数量</th>
|
||||
<th>订单金额</th>
|
||||
<th>已结款</th>
|
||||
<th>未结款</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in state.customerProductStatement" :key="'customer-product-' + item.partner_id + '-' + item.product_id">
|
||||
<td>{{ item.partner_name }}</td>
|
||||
<td>{{ item.product_sku || '-' }}</td>
|
||||
<td>{{ item.product_name }}</td>
|
||||
<td>{{ item.order_count }}</td>
|
||||
<td>{{ formatNumber(item.order_quantity) }}</td>
|
||||
<td>{{ formatCurrency(item.order_amount) }}</td>
|
||||
<td>{{ formatCurrency(item.settled_amount) }}</td>
|
||||
<td>{{ formatCurrency(item.outstanding_amount) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="table-container" style="margin-top: 16px;">
|
||||
<h3 style="margin-bottom: 12px;">供应商-商品追溯(周期)</h3>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>供应商</th>
|
||||
<th>SKU</th>
|
||||
<th>商品</th>
|
||||
<th>订单数</th>
|
||||
<th>数量</th>
|
||||
<th>订单金额</th>
|
||||
<th>已结款</th>
|
||||
<th>未结款</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in state.supplierProductStatement" :key="'supplier-product-' + item.partner_id + '-' + item.product_id">
|
||||
<td>{{ item.partner_name }}</td>
|
||||
<td>{{ item.product_sku || '-' }}</td>
|
||||
<td>{{ item.product_name }}</td>
|
||||
<td>{{ item.order_count }}</td>
|
||||
<td>{{ formatNumber(item.order_quantity) }}</td>
|
||||
<td>{{ formatCurrency(item.order_amount) }}</td>
|
||||
<td>{{ formatCurrency(item.settled_amount) }}</td>
|
||||
<td>{{ formatCurrency(item.outstanding_amount) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="table-container" style="margin-top: 16px;">
|
||||
<h3 style="margin-bottom: 12px;">最近财务流水</h3>
|
||||
<table class="data-table">
|
||||
@@ -2039,7 +2238,7 @@ const InventoryView = {
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="movement in state.movements" :key="movement.id">
|
||||
<td>{{ movement.product_name }}</td>
|
||||
<td>{{ movement.product_name }}{{ movement.product_sku ? ' (' + movement.product_sku + ')' : '' }}</td>
|
||||
<td>
|
||||
<span :class="['badge', movement.movement_type === 'in' ? 'badge-success' : movement.movement_type === 'out' ? 'badge-error' : 'badge-warning']">
|
||||
{{ movement.movement_type === 'in' ? '入库' : movement.movement_type === 'out' ? '出库' : '调整' }}
|
||||
@@ -2158,12 +2357,20 @@ const InventoryView = {
|
||||
<!-- 入库表单 -->
|
||||
<form v-else-if="state.modalType === 'stockIn'" @submit.prevent="stockIn">
|
||||
<div class="form-group">
|
||||
<label class="form-label">产品ID *</label>
|
||||
<input v-model.number="state.form.product_id" type="number" class="form-input" required placeholder="产品ID" />
|
||||
<label class="form-label">产品 *</label>
|
||||
<select v-model.number="state.form.product_id" class="form-input" required>
|
||||
<option v-for="product in state.products" :key="'stockin-product-' + product.id" :value="product.id">
|
||||
{{ product.sku }} - {{ product.name }}(ID: {{ product.id }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">仓库ID *</label>
|
||||
<input v-model.number="state.form.warehouse_id" type="number" class="form-input" required placeholder="仓库ID (默认1)" />
|
||||
<label class="form-label">仓库 *</label>
|
||||
<select v-model.number="state.form.warehouse_id" class="form-input" required>
|
||||
<option v-for="warehouse in state.warehouses" :key="'stockin-warehouse-' + warehouse.id" :value="warehouse.id">
|
||||
{{ warehouse.name }}{{ warehouse.code ? ' (' + warehouse.code + ')' : '' }}{{ warehouse.is_default ? ' [默认]' : '' }}(ID: {{ warehouse.id }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">数量 *</label>
|
||||
@@ -2186,12 +2393,20 @@ const InventoryView = {
|
||||
<!-- 出库表单 -->
|
||||
<form v-else-if="state.modalType === 'stockOut'" @submit.prevent="stockOut">
|
||||
<div class="form-group">
|
||||
<label class="form-label">产品ID *</label>
|
||||
<input v-model.number="state.form.product_id" type="number" class="form-input" required placeholder="产品ID" />
|
||||
<label class="form-label">产品 *</label>
|
||||
<select v-model.number="state.form.product_id" class="form-input" required>
|
||||
<option v-for="product in state.products" :key="'stockout-product-' + product.id" :value="product.id">
|
||||
{{ product.sku }} - {{ product.name }}(ID: {{ product.id }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">仓库ID *</label>
|
||||
<input v-model.number="state.form.warehouse_id" type="number" class="form-input" required placeholder="仓库ID (默认1)" />
|
||||
<label class="form-label">仓库 *</label>
|
||||
<select v-model.number="state.form.warehouse_id" class="form-input" required>
|
||||
<option v-for="warehouse in state.warehouses" :key="'stockout-warehouse-' + warehouse.id" :value="warehouse.id">
|
||||
{{ warehouse.name }}{{ warehouse.code ? ' (' + warehouse.code + ')' : '' }}{{ warehouse.is_default ? ' [默认]' : '' }}(ID: {{ warehouse.id }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">数量 *</label>
|
||||
|
||||
Reference in New Issue
Block a user