From 66f2bd2daa52a09d1905d833887c4be74f56c5e6 Mon Sep 17 00:00:00 2001 From: SZCJW <792430652@qq.com> Date: Sun, 15 Mar 2026 22:04:28 +0800 Subject: [PATCH] x --- src/api/inventory/finance_routes.py | 388 +++++++++++++++++- src/api/inventory/schemas/__init__.py | 8 +- src/api/inventory/schemas/finance_schemas.py | 57 +++ .../schemas/stock_movement_schemas.py | 3 + src/api/inventory/stock_movement_routes.py | 45 +- static/vue-app.js | 255 +++++++++++- 6 files changed, 721 insertions(+), 35 deletions(-) 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 = {
- - + +
- - + +
@@ -2186,12 +2393,20 @@ const InventoryView = {
- - + +
- - + +