diff --git a/src/api/inventory/finance_routes.py b/src/api/inventory/finance_routes.py
index e150186..3743f26 100644
--- a/src/api/inventory/finance_routes.py
+++ b/src/api/inventory/finance_routes.py
@@ -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,
+ )
+
diff --git a/src/api/inventory/schemas/__init__.py b/src/api/inventory/schemas/__init__.py
index 7636e02..6c18655 100644
--- a/src/api/inventory/schemas/__init__.py
+++ b/src/api/inventory/schemas/__init__.py
@@ -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",
]
diff --git a/src/api/inventory/schemas/finance_schemas.py b/src/api/inventory/schemas/finance_schemas.py
index 96058c0..0ff9931 100644
--- a/src/api/inventory/schemas/finance_schemas.py
+++ b/src/api/inventory/schemas/finance_schemas.py
@@ -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] = []
diff --git a/src/api/inventory/schemas/stock_movement_schemas.py b/src/api/inventory/schemas/stock_movement_schemas.py
index 881d127..4f92639 100644
--- a/src/api/inventory/schemas/stock_movement_schemas.py
+++ b/src/api/inventory/schemas/stock_movement_schemas.py
@@ -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
diff --git a/src/api/inventory/stock_movement_routes.py b/src/api/inventory/stock_movement_routes.py
index b1b294c..8758a2b 100644
--- a/src/api/inventory/stock_movement_routes.py
+++ b/src/api/inventory/stock_movement_routes.py
@@ -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,
diff --git a/static/vue-app.js b/static/vue-app.js
index 4e489ae..42bec82 100644
--- a/static/vue-app.js
+++ b/static/vue-app.js
@@ -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 = {
+
+
+
+
+
+
+
+
+
+
+
+
统计周期:{{ state.financeSummary?.period_label || '-' }}
+
+
+
🧾
@@ -1985,19 +2068,135 @@ const InventoryView = {
💵
-
{{ formatCurrency(state.financeSummary?.monthly_receipt_total || 0) }}
-
本月收款
+
{{ formatCurrency(state.financeSummary?.period_receipt_total || 0) }}
+
周期收款
🏦
-
{{ formatCurrency(state.financeSummary?.monthly_payment_total || 0) }}
-
本月付款
+
{{ formatCurrency(state.financeSummary?.period_payment_total || 0) }}
+
周期付款
+
+
客户账款(周期)
+
+
+
+ | 客户 |
+ 订单数 |
+ 流水数 |
+ 订单金额 |
+ 订单已收 |
+ 实收流水 |
+ 应收余额 |
+
+
+
+
+ | {{ item.partner_name }} |
+ {{ item.order_count }} |
+ {{ item.transaction_count }} |
+ {{ formatCurrency(item.order_total) }} |
+ {{ formatCurrency(item.settled_total) }} |
+ {{ formatCurrency(item.transaction_total) }} |
+ {{ formatCurrency(item.outstanding_total) }} |
+
+
+
+
+
+
+
供应商账款(周期)
+
+
+
+ | 供应商 |
+ 订单数 |
+ 流水数 |
+ 订单金额 |
+ 订单已付 |
+ 实付流水 |
+ 应付余额 |
+
+
+
+
+ | {{ item.partner_name }} |
+ {{ item.order_count }} |
+ {{ item.transaction_count }} |
+ {{ formatCurrency(item.order_total) }} |
+ {{ formatCurrency(item.settled_total) }} |
+ {{ formatCurrency(item.transaction_total) }} |
+ {{ formatCurrency(item.outstanding_total) }} |
+
+
+
+
+
+
+
客户-商品追溯(周期)
+
+
+
+ | 客户 |
+ SKU |
+ 商品 |
+ 订单数 |
+ 数量 |
+ 订单金额 |
+ 已结款 |
+ 未结款 |
+
+
+
+
+ | {{ item.partner_name }} |
+ {{ item.product_sku || '-' }} |
+ {{ item.product_name }} |
+ {{ item.order_count }} |
+ {{ formatNumber(item.order_quantity) }} |
+ {{ formatCurrency(item.order_amount) }} |
+ {{ formatCurrency(item.settled_amount) }} |
+ {{ formatCurrency(item.outstanding_amount) }} |
+
+
+
+
+
+
+
供应商-商品追溯(周期)
+
+
+
+ | 供应商 |
+ SKU |
+ 商品 |
+ 订单数 |
+ 数量 |
+ 订单金额 |
+ 已结款 |
+ 未结款 |
+
+
+
+
+ | {{ item.partner_name }} |
+ {{ item.product_sku || '-' }} |
+ {{ item.product_name }} |
+ {{ item.order_count }} |
+ {{ formatNumber(item.order_quantity) }} |
+ {{ formatCurrency(item.order_amount) }} |
+ {{ formatCurrency(item.settled_amount) }} |
+ {{ formatCurrency(item.outstanding_amount) }} |
+
+
+
+
+
最近财务流水
@@ -2039,7 +2238,7 @@ const InventoryView = {
- | {{ movement.product_name }} |
+ {{ movement.product_name }}{{ movement.product_sku ? ' (' + movement.product_sku + ')' : '' }} |
{{ movement.movement_type === 'in' ? '入库' : movement.movement_type === 'out' ? '出库' : '调整' }}
@@ -2158,12 +2357,20 @@ const InventoryView = {
|