This commit is contained in:
2026-03-15 22:04:28 +08:00
parent 48853a988d
commit 66f2bd2daa
6 changed files with 721 additions and 35 deletions
+381 -7
View File
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from typing import Optional, List
from typing import Optional, List, Dict, Tuple
from datetime import datetime
from database.database import get_db_session
@@ -11,8 +11,11 @@ from models.database import (
User,
Customer,
Supplier,
Product,
SalesOrder,
SalesOrderItem,
PurchaseOrder,
PurchaseOrderItem,
FinanceTransaction,
FinanceAllocation,
)
@@ -23,6 +26,10 @@ from .schemas import (
FinanceSummaryResponse,
ReceivableItemResponse,
PayableItemResponse,
FinancePartnerStatementResponse,
PartnerStatementItemResponse,
FinancePartnerProductStatementResponse,
PartnerProductStatementItemResponse,
)
from .utils import generate_order_no
@@ -62,6 +69,31 @@ def _validate_allocation_total(transaction_amount: float, allocation_amounts: Li
raise HTTPException(status_code=400, detail="核销总额不能大于单据金额")
def _resolve_period_scope(year: Optional[int], quarter: Optional[int]) -> Tuple[int, Optional[int], str, datetime, datetime]:
now = datetime.now()
selected_year = year or now.year
if selected_year < 2000 or selected_year > 2100:
raise HTTPException(status_code=400, detail="年份超出支持范围")
if quarter is not None and quarter not in [1, 2, 3, 4]:
raise HTTPException(status_code=400, detail="季度必须是1-4")
if quarter is None:
period_start = datetime(selected_year, 1, 1)
period_end = datetime(selected_year + 1, 1, 1)
period_label = f"{selected_year}年"
else:
start_month = (quarter - 1) * 3 + 1
period_start = datetime(selected_year, start_month, 1)
if quarter == 4:
period_end = datetime(selected_year + 1, 1, 1)
else:
period_end = datetime(selected_year, start_month + 3, 1)
period_label = f"{selected_year}年Q{quarter}"
return selected_year, quarter, period_label, period_start, period_end
@router.post("/receipts", response_model=FinanceTransactionResponse, status_code=201)
async def create_receipt(
payload: ReceiptCreate,
@@ -198,6 +230,8 @@ async def create_payment(
async def list_transactions(
txn_type: Optional[str] = None,
status: Optional[str] = "confirmed",
year: Optional[int] = Query(None, ge=2000, le=2100),
quarter: Optional[int] = Query(None, ge=1, le=4),
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
db_session: AsyncSession = Depends(get_db_session),
@@ -212,6 +246,9 @@ async def list_transactions(
query = query.where(FinanceTransaction.txn_type == txn_type)
if status:
query = query.where(FinanceTransaction.status == status)
if year is not None or quarter is not None:
_, _, _, period_start, period_end = _resolve_period_scope(year, quarter)
query = query.where(FinanceTransaction.txn_date >= period_start).where(FinanceTransaction.txn_date < period_end)
query = query.offset(skip).limit(limit)
result = await db_session.execute(query)
rows = result.scalars().all()
@@ -254,19 +291,23 @@ async def void_transaction(
@router.get("/receivables", response_model=List[ReceivableItemResponse])
async def list_receivables(
year: Optional[int] = Query(None, ge=2000, le=2100),
quarter: Optional[int] = Query(None, ge=1, le=4),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user),
):
result = await db_session.execute(
query = (
select(SalesOrder, Customer)
.join(Customer, SalesOrder.customer_id == Customer.id)
.where((SalesOrder.total_amount - SalesOrder.received_amount) > 0)
.order_by(SalesOrder.created_at.desc())
.offset(skip)
.limit(limit)
)
if year is not None or quarter is not None:
_, _, _, period_start, period_end = _resolve_period_scope(year, quarter)
query = query.where(SalesOrder.order_date >= period_start).where(SalesOrder.order_date < period_end)
result = await db_session.execute(query.offset(skip).limit(limit))
rows = []
for order, customer in result.all():
receivable_amount = (order.total_amount or 0) - (order.received_amount or 0)
@@ -288,19 +329,23 @@ async def list_receivables(
@router.get("/payables", response_model=List[PayableItemResponse])
async def list_payables(
year: Optional[int] = Query(None, ge=2000, le=2100),
quarter: Optional[int] = Query(None, ge=1, le=4),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user),
):
result = await db_session.execute(
query = (
select(PurchaseOrder, Supplier)
.join(Supplier, PurchaseOrder.supplier_id == Supplier.id)
.where((PurchaseOrder.total_amount - PurchaseOrder.paid_amount) > 0)
.order_by(PurchaseOrder.created_at.desc())
.offset(skip)
.limit(limit)
)
if year is not None or quarter is not None:
_, _, _, period_start, period_end = _resolve_period_scope(year, quarter)
query = query.where(PurchaseOrder.order_date >= period_start).where(PurchaseOrder.order_date < period_end)
result = await db_session.execute(query.offset(skip).limit(limit))
rows = []
for order, supplier in result.all():
payable_amount = (order.total_amount or 0) - (order.paid_amount or 0)
@@ -322,6 +367,8 @@ async def list_payables(
@router.get("/summary", response_model=FinanceSummaryResponse)
async def get_finance_summary(
year: Optional[int] = Query(None, ge=2000, le=2100),
quarter: Optional[int] = Query(None, ge=1, le=4),
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user),
):
@@ -350,12 +397,339 @@ async def get_finance_summary(
.where(FinanceTransaction.txn_date >= month_start)
) or 0
selected_year, selected_quarter, period_label, period_start, period_end = _resolve_period_scope(year, quarter)
period_receipt_total = await db_session.scalar(
select(func.coalesce(func.sum(FinanceTransaction.amount), 0))
.where(FinanceTransaction.txn_type == "receipt")
.where(FinanceTransaction.status == "confirmed")
.where(FinanceTransaction.txn_date >= period_start)
.where(FinanceTransaction.txn_date < period_end)
) or 0
period_payment_total = await db_session.scalar(
select(func.coalesce(func.sum(FinanceTransaction.amount), 0))
.where(FinanceTransaction.txn_type == "payment")
.where(FinanceTransaction.status == "confirmed")
.where(FinanceTransaction.txn_date >= period_start)
.where(FinanceTransaction.txn_date < period_end)
) or 0
return FinanceSummaryResponse(
receivable_total=round(float(receivable_total), 2),
payable_total=round(float(payable_total), 2),
monthly_receipt_total=round(float(monthly_receipt_total), 2),
monthly_payment_total=round(float(monthly_payment_total), 2),
selected_year=selected_year,
selected_quarter=selected_quarter,
period_label=period_label,
period_receipt_total=round(float(period_receipt_total), 2),
period_payment_total=round(float(period_payment_total), 2),
overdue_receivable_count=0,
overdue_payable_count=0,
)
@router.get("/partner-statement/{partner_type}", response_model=FinancePartnerStatementResponse)
async def get_partner_statement(
partner_type: str,
year: Optional[int] = Query(None, ge=2000, le=2100),
quarter: Optional[int] = Query(None, ge=1, le=4),
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user),
):
if partner_type not in ["customer", "supplier"]:
raise HTTPException(status_code=400, detail="partner_type 必须是 customer 或 supplier")
selected_year, selected_quarter, period_label, period_start, period_end = _resolve_period_scope(year, quarter)
stats_map: Dict[int, Dict] = {}
if partner_type == "customer":
order_rows = await db_session.execute(
select(SalesOrder, Customer)
.join(Customer, SalesOrder.customer_id == Customer.id)
.where(SalesOrder.order_date >= period_start)
.where(SalesOrder.order_date < period_end)
.where(Customer.is_active == True)
)
for order, customer in order_rows.all():
partner_stat = stats_map.setdefault(
customer.id,
{
"partner_id": customer.id,
"partner_name": customer.name,
"order_count": 0,
"transaction_count": 0,
"order_total": 0.0,
"settled_total": 0.0,
"transaction_total": 0.0,
"outstanding_total": 0.0,
},
)
total_amount = float(order.total_amount or 0)
settled_amount = float(order.received_amount or 0)
outstanding = max(total_amount - settled_amount, 0.0)
partner_stat["order_count"] += 1
partner_stat["order_total"] += total_amount
partner_stat["settled_total"] += settled_amount
partner_stat["outstanding_total"] += outstanding
transaction_rows = await db_session.execute(
select(FinanceTransaction)
.where(FinanceTransaction.partner_type == "customer")
.where(FinanceTransaction.txn_type == "receipt")
.where(FinanceTransaction.status == "confirmed")
.where(FinanceTransaction.txn_date >= period_start)
.where(FinanceTransaction.txn_date < period_end)
)
for txn in transaction_rows.scalars().all():
partner_stat = stats_map.setdefault(
txn.partner_id,
{
"partner_id": txn.partner_id,
"partner_name": f"客户#{txn.partner_id}",
"order_count": 0,
"transaction_count": 0,
"order_total": 0.0,
"settled_total": 0.0,
"transaction_total": 0.0,
"outstanding_total": 0.0,
},
)
partner_stat["transaction_count"] += 1
partner_stat["transaction_total"] += float(txn.amount or 0)
else:
order_rows = await db_session.execute(
select(PurchaseOrder, Supplier)
.join(Supplier, PurchaseOrder.supplier_id == Supplier.id)
.where(PurchaseOrder.order_date >= period_start)
.where(PurchaseOrder.order_date < period_end)
.where(Supplier.is_active == True)
)
for order, supplier in order_rows.all():
partner_stat = stats_map.setdefault(
supplier.id,
{
"partner_id": supplier.id,
"partner_name": supplier.name,
"order_count": 0,
"transaction_count": 0,
"order_total": 0.0,
"settled_total": 0.0,
"transaction_total": 0.0,
"outstanding_total": 0.0,
},
)
total_amount = float(order.total_amount or 0)
settled_amount = float(order.paid_amount or 0)
outstanding = max(total_amount - settled_amount, 0.0)
partner_stat["order_count"] += 1
partner_stat["order_total"] += total_amount
partner_stat["settled_total"] += settled_amount
partner_stat["outstanding_total"] += outstanding
transaction_rows = await db_session.execute(
select(FinanceTransaction)
.where(FinanceTransaction.partner_type == "supplier")
.where(FinanceTransaction.txn_type == "payment")
.where(FinanceTransaction.status == "confirmed")
.where(FinanceTransaction.txn_date >= period_start)
.where(FinanceTransaction.txn_date < period_end)
)
for txn in transaction_rows.scalars().all():
partner_stat = stats_map.setdefault(
txn.partner_id,
{
"partner_id": txn.partner_id,
"partner_name": f"供应商#{txn.partner_id}",
"order_count": 0,
"transaction_count": 0,
"order_total": 0.0,
"settled_total": 0.0,
"transaction_total": 0.0,
"outstanding_total": 0.0,
},
)
partner_stat["transaction_count"] += 1
partner_stat["transaction_total"] += float(txn.amount or 0)
missing_partner_ids = [pid for pid, item in stats_map.items() if "#" in item["partner_name"]]
if missing_partner_ids:
if partner_type == "customer":
name_rows = await db_session.execute(
select(Customer.id, Customer.name).where(Customer.id.in_(missing_partner_ids))
)
else:
name_rows = await db_session.execute(
select(Supplier.id, Supplier.name).where(Supplier.id.in_(missing_partner_ids))
)
name_map = {row[0]: row[1] for row in name_rows.all()}
for pid in missing_partner_ids:
if pid in name_map:
stats_map[pid]["partner_name"] = name_map[pid]
items = [
PartnerStatementItemResponse(
partner_id=item["partner_id"],
partner_name=item["partner_name"],
order_count=item["order_count"],
transaction_count=item["transaction_count"],
order_total=round(float(item["order_total"]), 2),
settled_total=round(float(item["settled_total"]), 2),
transaction_total=round(float(item["transaction_total"]), 2),
outstanding_total=round(float(item["outstanding_total"]), 2),
period_year=selected_year,
period_quarter=selected_quarter,
)
for item in sorted(stats_map.values(), key=lambda x: (x["outstanding_total"], x["order_total"]), reverse=True)
]
return FinancePartnerStatementResponse(
partner_type=partner_type,
year=selected_year,
quarter=selected_quarter,
period_label=period_label,
order_total=round(float(sum(item.order_total for item in items)), 2),
settled_total=round(float(sum(item.settled_total for item in items)), 2),
transaction_total=round(float(sum(item.transaction_total for item in items)), 2),
outstanding_total=round(float(sum(item.outstanding_total for item in items)), 2),
items=items,
)
@router.get("/partner-product-statement/{partner_type}", response_model=FinancePartnerProductStatementResponse)
async def get_partner_product_statement(
partner_type: str,
partner_id: Optional[int] = Query(None, ge=1),
year: Optional[int] = Query(None, ge=2000, le=2100),
quarter: Optional[int] = Query(None, ge=1, le=4),
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user),
):
if partner_type not in ["customer", "supplier"]:
raise HTTPException(status_code=400, detail="partner_type 必须是 customer 或 supplier")
selected_year, selected_quarter, period_label, period_start, period_end = _resolve_period_scope(year, quarter)
stats_map: Dict[Tuple[int, int], Dict] = {}
if partner_type == "customer":
query = (
select(SalesOrderItem, SalesOrder, Product, Customer)
.join(SalesOrder, SalesOrderItem.order_id == SalesOrder.id)
.join(Product, SalesOrderItem.product_id == Product.id)
.join(Customer, SalesOrder.customer_id == Customer.id)
.where(SalesOrder.order_date >= period_start)
.where(SalesOrder.order_date < period_end)
.where(Customer.is_active == True)
)
if partner_id:
query = query.where(Customer.id == partner_id)
result = await db_session.execute(query)
for item, order, product, customer in result.all():
map_key = (customer.id, product.id)
stat = stats_map.setdefault(
map_key,
{
"partner_id": customer.id,
"partner_name": customer.name,
"product_id": product.id,
"product_sku": product.sku,
"product_name": product.name,
"order_ids": set(),
"order_quantity": 0.0,
"order_amount": 0.0,
"settled_amount": 0.0,
"outstanding_amount": 0.0,
},
)
item_amount = float(item.amount or 0)
order_total = float(order.total_amount or 0)
order_settled = max(float(order.received_amount or 0), 0.0)
ratio = (item_amount / order_total) if order_total > 1e-9 else 0.0
item_settled = min(item_amount, order_settled * ratio)
item_outstanding = max(item_amount - item_settled, 0.0)
stat["order_ids"].add(order.id)
stat["order_quantity"] += float(item.quantity or 0)
stat["order_amount"] += item_amount
stat["settled_amount"] += item_settled
stat["outstanding_amount"] += item_outstanding
else:
query = (
select(PurchaseOrderItem, PurchaseOrder, Product, Supplier)
.join(PurchaseOrder, PurchaseOrderItem.order_id == PurchaseOrder.id)
.join(Product, PurchaseOrderItem.product_id == Product.id)
.join(Supplier, PurchaseOrder.supplier_id == Supplier.id)
.where(PurchaseOrder.order_date >= period_start)
.where(PurchaseOrder.order_date < period_end)
.where(Supplier.is_active == True)
)
if partner_id:
query = query.where(Supplier.id == partner_id)
result = await db_session.execute(query)
for item, order, product, supplier in result.all():
map_key = (supplier.id, product.id)
stat = stats_map.setdefault(
map_key,
{
"partner_id": supplier.id,
"partner_name": supplier.name,
"product_id": product.id,
"product_sku": product.sku,
"product_name": product.name,
"order_ids": set(),
"order_quantity": 0.0,
"order_amount": 0.0,
"settled_amount": 0.0,
"outstanding_amount": 0.0,
},
)
item_amount = float(item.amount or 0)
order_total = float(order.total_amount or 0)
order_settled = max(float(order.paid_amount or 0), 0.0)
ratio = (item_amount / order_total) if order_total > 1e-9 else 0.0
item_settled = min(item_amount, order_settled * ratio)
item_outstanding = max(item_amount - item_settled, 0.0)
stat["order_ids"].add(order.id)
stat["order_quantity"] += float(item.quantity or 0)
stat["order_amount"] += item_amount
stat["settled_amount"] += item_settled
stat["outstanding_amount"] += item_outstanding
items = [
PartnerProductStatementItemResponse(
partner_id=item["partner_id"],
partner_name=item["partner_name"],
product_id=item["product_id"],
product_sku=item["product_sku"],
product_name=item["product_name"],
order_count=len(item["order_ids"]),
order_quantity=round(float(item["order_quantity"]), 2),
order_amount=round(float(item["order_amount"]), 2),
settled_amount=round(float(item["settled_amount"]), 2),
outstanding_amount=round(float(item["outstanding_amount"]), 2),
period_year=selected_year,
period_quarter=selected_quarter,
)
for item in sorted(
stats_map.values(),
key=lambda x: (x["outstanding_amount"], x["order_amount"]),
reverse=True
)
]
return FinancePartnerProductStatementResponse(
partner_type=partner_type,
year=selected_year,
quarter=selected_quarter,
period_label=period_label,
partner_id=partner_id,
order_amount_total=round(float(sum(item.order_amount for item in items)), 2),
settled_amount_total=round(float(sum(item.settled_amount for item in items)), 2),
outstanding_amount_total=round(float(sum(item.outstanding_amount for item in items)), 2),
items=items,
)
+7 -1
View File
@@ -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
+38 -7
View File
@@ -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
View File
@@ -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>