init
This commit is contained in:
+23
-3
@@ -1,5 +1,6 @@
|
||||
from celery_app import app
|
||||
from moldinsight.services.processing_service import processing_service
|
||||
from shared.config.settings import settings
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -11,11 +12,30 @@ def process_stp_task(self, task_id: str, file_path: str, stp_file_id: int,
|
||||
"""Celery 任务:异步处理 STP 文件生成模具型腔"""
|
||||
import asyncio
|
||||
|
||||
async def _run():
|
||||
# Celery 进程不执行 FastAPI startup,必须在此处显式建立连接:
|
||||
# - redis.asyncio 客户端绑定创建它的循环,而本任务经 asyncio.run 每次新建循环,
|
||||
# 故每任务需 reconnect();
|
||||
# - RustFS(Minio) 为同步客户端,不绑定循环,连一次后跨任务复用。
|
||||
from shared.services.redis_task_manager import redis_task_manager
|
||||
from moldinsight.storage.rustfs_storage import rustfs_manager
|
||||
|
||||
await redis_task_manager.reconnect()
|
||||
if not rustfs_manager.is_connected:
|
||||
await rustfs_manager.connect(
|
||||
endpoint=settings.RUSTFS_ENDPOINT,
|
||||
access_key=settings.RUSTFS_ACCESS_KEY,
|
||||
secret_key=settings.RUSTFS_SECRET_KEY,
|
||||
timeout=settings.RUSTFS_TIMEOUT,
|
||||
)
|
||||
|
||||
await processing_service.process_file_with_storage(
|
||||
task_id, file_path, stp_file_id, process_params
|
||||
)
|
||||
|
||||
try:
|
||||
logger.info(f"[celery] 开始处理: {task_id}")
|
||||
asyncio.run(processing_service.process_file_with_storage(
|
||||
task_id, file_path, stp_file_id, process_params
|
||||
))
|
||||
asyncio.run(_run())
|
||||
logger.info(f"[celery] 处理完成: {task_id}")
|
||||
return {"task_id": task_id, "status": "completed"}
|
||||
except Exception as exc:
|
||||
|
||||
@@ -18,7 +18,7 @@ from datetime import datetime
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user, get_current_admin_user
|
||||
from shared.models.database import User, Customer
|
||||
from .schemas import CustomerCreate, CustomerResponse
|
||||
from ..schemas import CustomerCreate, CustomerResponse
|
||||
|
||||
router = APIRouter(prefix="/customers", tags=["客户管理"])
|
||||
|
||||
|
||||
@@ -1,26 +1,15 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
"""财务路由层 - 薄路由
|
||||
|
||||
业务逻辑下沉至 inventory.services.finance_service,路由只做参数校验与响应组装。
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
from typing import Optional, List, Dict, Tuple
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Optional, List
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.database import (
|
||||
User,
|
||||
Customer,
|
||||
Supplier,
|
||||
Product,
|
||||
SalesOrder,
|
||||
SalesOrderItem,
|
||||
PurchaseOrder,
|
||||
PurchaseOrderItem,
|
||||
FinanceTransaction,
|
||||
FinanceAllocation,
|
||||
)
|
||||
from .schemas import (
|
||||
from shared.models.database import User
|
||||
from ..schemas import (
|
||||
ReceiptCreate,
|
||||
PaymentCreate,
|
||||
FinanceTransactionResponse,
|
||||
@@ -28,141 +17,21 @@ from .schemas import (
|
||||
ReceivableItemResponse,
|
||||
PayableItemResponse,
|
||||
FinancePartnerStatementResponse,
|
||||
PartnerStatementItemResponse,
|
||||
FinancePartnerProductStatementResponse,
|
||||
PartnerProductStatementItemResponse,
|
||||
PaginatedResponse,
|
||||
)
|
||||
from .utils import generate_order_no
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
from ..services.finance_service import finance_service
|
||||
|
||||
router = APIRouter(prefix="/finance", tags=["财务管理"])
|
||||
|
||||
|
||||
def _build_transaction_response(txn: FinanceTransaction) -> FinanceTransactionResponse:
|
||||
allocations = [
|
||||
{
|
||||
"id": item.id,
|
||||
"order_type": item.order_type,
|
||||
"order_id": item.order_id,
|
||||
"allocated_amount": item.allocated_amount,
|
||||
}
|
||||
for item in txn.allocations
|
||||
]
|
||||
return FinanceTransactionResponse(
|
||||
id=txn.id,
|
||||
txn_no=txn.txn_no,
|
||||
txn_type=txn.txn_type,
|
||||
partner_type=txn.partner_type,
|
||||
partner_id=txn.partner_id,
|
||||
amount=txn.amount,
|
||||
txn_date=txn.txn_date,
|
||||
method=txn.method,
|
||||
account_name=txn.account_name,
|
||||
status=txn.status,
|
||||
remark=txn.remark,
|
||||
created_at=txn.created_at,
|
||||
allocations=allocations,
|
||||
)
|
||||
|
||||
|
||||
def _validate_allocation_total(transaction_amount: float, allocation_amounts: List[float]):
|
||||
allocated_total = sum(allocation_amounts)
|
||||
if allocated_total - transaction_amount > 1e-6:
|
||||
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,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
customer_result = await db_session.execute(
|
||||
select(Customer).where(Customer.id == payload.customer_id, Customer.is_active == True)
|
||||
)
|
||||
customer = customer_result.scalar_one_or_none()
|
||||
if not customer:
|
||||
raise HTTPException(status_code=404, detail="客户不存在")
|
||||
|
||||
_validate_allocation_total(payload.amount, [item.allocated_amount for item in payload.allocations])
|
||||
|
||||
txn = FinanceTransaction(
|
||||
txn_no=generate_order_no("RC"),
|
||||
txn_type="receipt",
|
||||
partner_type="customer",
|
||||
partner_id=payload.customer_id,
|
||||
amount=payload.amount,
|
||||
txn_date=payload.txn_date or datetime.now(),
|
||||
method=payload.method,
|
||||
account_name=payload.account_name,
|
||||
status="confirmed",
|
||||
remark=payload.remark,
|
||||
operator_id=current_user.id,
|
||||
)
|
||||
db_session.add(txn)
|
||||
await db_session.flush()
|
||||
|
||||
for allocation in payload.allocations:
|
||||
if allocation.order_type != "sales":
|
||||
raise HTTPException(status_code=400, detail="收款单只允许核销销售订单")
|
||||
|
||||
order_result = await db_session.execute(
|
||||
select(SalesOrder).where(SalesOrder.id == allocation.order_id, SalesOrder.customer_id == payload.customer_id)
|
||||
)
|
||||
sales_order = order_result.scalar_one_or_none()
|
||||
if not sales_order:
|
||||
raise HTTPException(status_code=404, detail=f"销售订单不存在: {allocation.order_id}")
|
||||
|
||||
remaining = (sales_order.total_amount or 0) - (sales_order.received_amount or 0)
|
||||
if allocation.allocated_amount - remaining > 1e-6:
|
||||
raise HTTPException(status_code=400, detail=f"销售订单核销超额: {sales_order.order_no}")
|
||||
|
||||
db_session.add(
|
||||
FinanceAllocation(
|
||||
transaction_id=txn.id,
|
||||
order_type="sales",
|
||||
order_id=sales_order.id,
|
||||
allocated_amount=allocation.allocated_amount,
|
||||
)
|
||||
)
|
||||
sales_order.received_amount = (sales_order.received_amount or 0) + allocation.allocated_amount
|
||||
|
||||
await db_session.commit()
|
||||
result = await db_session.execute(
|
||||
select(FinanceTransaction)
|
||||
.options(selectinload(FinanceTransaction.allocations))
|
||||
.where(FinanceTransaction.id == txn.id)
|
||||
)
|
||||
created = result.scalar_one()
|
||||
return _build_transaction_response(created)
|
||||
return await finance_service.create_receipt(db_session, payload, current_user)
|
||||
|
||||
|
||||
@router.post("/payments", response_model=FinanceTransactionResponse, status_code=201)
|
||||
@@ -171,64 +40,7 @@ async def create_payment(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
supplier_result = await db_session.execute(
|
||||
select(Supplier).where(Supplier.id == payload.supplier_id, Supplier.is_active == True)
|
||||
)
|
||||
supplier = supplier_result.scalar_one_or_none()
|
||||
if not supplier:
|
||||
raise HTTPException(status_code=404, detail="供应商不存在")
|
||||
|
||||
_validate_allocation_total(payload.amount, [item.allocated_amount for item in payload.allocations])
|
||||
|
||||
txn = FinanceTransaction(
|
||||
txn_no=generate_order_no("PY"),
|
||||
txn_type="payment",
|
||||
partner_type="supplier",
|
||||
partner_id=payload.supplier_id,
|
||||
amount=payload.amount,
|
||||
txn_date=payload.txn_date or datetime.now(),
|
||||
method=payload.method,
|
||||
account_name=payload.account_name,
|
||||
status="confirmed",
|
||||
remark=payload.remark,
|
||||
operator_id=current_user.id,
|
||||
)
|
||||
db_session.add(txn)
|
||||
await db_session.flush()
|
||||
|
||||
for allocation in payload.allocations:
|
||||
if allocation.order_type != "purchase":
|
||||
raise HTTPException(status_code=400, detail="付款单只允许核销采购订单")
|
||||
|
||||
order_result = await db_session.execute(
|
||||
select(PurchaseOrder).where(PurchaseOrder.id == allocation.order_id, PurchaseOrder.supplier_id == payload.supplier_id)
|
||||
)
|
||||
purchase_order = order_result.scalar_one_or_none()
|
||||
if not purchase_order:
|
||||
raise HTTPException(status_code=404, detail=f"采购订单不存在: {allocation.order_id}")
|
||||
|
||||
remaining = (purchase_order.total_amount or 0) - (purchase_order.paid_amount or 0)
|
||||
if allocation.allocated_amount - remaining > 1e-6:
|
||||
raise HTTPException(status_code=400, detail=f"采购订单核销超额: {purchase_order.order_no}")
|
||||
|
||||
db_session.add(
|
||||
FinanceAllocation(
|
||||
transaction_id=txn.id,
|
||||
order_type="purchase",
|
||||
order_id=purchase_order.id,
|
||||
allocated_amount=allocation.allocated_amount,
|
||||
)
|
||||
)
|
||||
purchase_order.paid_amount = (purchase_order.paid_amount or 0) + allocation.allocated_amount
|
||||
|
||||
await db_session.commit()
|
||||
result = await db_session.execute(
|
||||
select(FinanceTransaction)
|
||||
.options(selectinload(FinanceTransaction.allocations))
|
||||
.where(FinanceTransaction.id == txn.id)
|
||||
)
|
||||
created = result.scalar_one()
|
||||
return _build_transaction_response(created)
|
||||
return await finance_service.create_payment(db_session, payload, current_user)
|
||||
|
||||
|
||||
@router.get("/transactions", response_model=PaginatedResponse[FinanceTransactionResponse])
|
||||
@@ -242,29 +54,7 @@ async def list_transactions(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
base_query = (
|
||||
select(FinanceTransaction)
|
||||
.options(selectinload(FinanceTransaction.allocations))
|
||||
.order_by(FinanceTransaction.created_at.desc())
|
||||
)
|
||||
if txn_type:
|
||||
base_query = base_query.where(FinanceTransaction.txn_type == txn_type)
|
||||
if status:
|
||||
base_query = base_query.where(FinanceTransaction.status == status)
|
||||
if year is not None or quarter is not None:
|
||||
_, _, _, period_start, period_end = _resolve_period_scope(year, quarter)
|
||||
base_query = base_query.where(FinanceTransaction.txn_date >= period_start).where(FinanceTransaction.txn_date < period_end)
|
||||
|
||||
count_query = select(func.count()).select_from(base_query.subquery())
|
||||
total = await db_session.scalar(count_query) or 0
|
||||
|
||||
query = base_query.offset(skip).limit(limit)
|
||||
result = await db_session.execute(query)
|
||||
rows = result.scalars().all()
|
||||
return PaginatedResponse(
|
||||
items=[_build_transaction_response(item) for item in rows],
|
||||
total=total, skip=skip, limit=limit
|
||||
)
|
||||
return await finance_service.list_transactions(db_session, txn_type, status, year, quarter, skip, limit)
|
||||
|
||||
|
||||
@router.post("/transactions/{transaction_id}/void")
|
||||
@@ -273,36 +63,7 @@ async def void_transaction(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
result = await db_session.execute(
|
||||
select(FinanceTransaction)
|
||||
.options(selectinload(FinanceTransaction.allocations))
|
||||
.where(FinanceTransaction.id == transaction_id)
|
||||
)
|
||||
txn = result.scalar_one_or_none()
|
||||
if not txn:
|
||||
raise HTTPException(status_code=404, detail="财务单据不存在")
|
||||
if txn.status == "voided":
|
||||
return {"message": "单据已作废"}
|
||||
|
||||
for allocation in txn.allocations:
|
||||
if allocation.order_type == "sales":
|
||||
sales_result = await db_session.execute(select(SalesOrder).where(SalesOrder.id == allocation.order_id))
|
||||
sales_order = sales_result.scalar_one_or_none()
|
||||
if sales_order:
|
||||
sales_order.received_amount = max((sales_order.received_amount or 0) - allocation.allocated_amount, 0)
|
||||
elif allocation.order_type == "purchase":
|
||||
purchase_result = await db_session.execute(select(PurchaseOrder).where(PurchaseOrder.id == allocation.order_id))
|
||||
purchase_order = purchase_result.scalar_one_or_none()
|
||||
if purchase_order:
|
||||
purchase_order.paid_amount = max((purchase_order.paid_amount or 0) - allocation.allocated_amount, 0)
|
||||
|
||||
txn.status = "voided"
|
||||
await db_session.commit()
|
||||
logger.warning(
|
||||
"财务单据已作废: txn_no=%s txn_type=%s amount=%s operator_id=%s",
|
||||
txn.txn_no, txn.txn_type, txn.amount, current_user.id
|
||||
)
|
||||
return {"message": "单据已作废"}
|
||||
return await finance_service.void_transaction(db_session, transaction_id, current_user)
|
||||
|
||||
|
||||
@router.get("/receivables", response_model=List[ReceivableItemResponse])
|
||||
@@ -314,33 +75,7 @@ async def list_receivables(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
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())
|
||||
)
|
||||
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)
|
||||
rows.append(
|
||||
ReceivableItemResponse(
|
||||
order_id=order.id,
|
||||
order_no=order.order_no,
|
||||
customer_id=customer.id,
|
||||
customer_name=customer.name,
|
||||
order_date=order.order_date,
|
||||
total_amount=order.total_amount or 0,
|
||||
received_amount=order.received_amount or 0,
|
||||
receivable_amount=receivable_amount,
|
||||
status=order.status,
|
||||
)
|
||||
)
|
||||
return rows
|
||||
return await finance_service.list_receivables(db_session, year, quarter, skip, limit)
|
||||
|
||||
|
||||
@router.get("/payables", response_model=List[PayableItemResponse])
|
||||
@@ -352,33 +87,7 @@ async def list_payables(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
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())
|
||||
)
|
||||
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)
|
||||
rows.append(
|
||||
PayableItemResponse(
|
||||
order_id=order.id,
|
||||
order_no=order.order_no,
|
||||
supplier_id=supplier.id,
|
||||
supplier_name=supplier.name,
|
||||
order_date=order.order_date,
|
||||
total_amount=order.total_amount or 0,
|
||||
paid_amount=order.paid_amount or 0,
|
||||
payable_amount=payable_amount,
|
||||
status=order.status,
|
||||
)
|
||||
)
|
||||
return rows
|
||||
return await finance_service.list_payables(db_session, year, quarter, skip, limit)
|
||||
|
||||
|
||||
@router.get("/summary", response_model=FinanceSummaryResponse)
|
||||
@@ -388,60 +97,7 @@ async def get_finance_summary(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
receivable_total = await db_session.scalar(
|
||||
select(func.coalesce(func.sum(SalesOrder.total_amount - SalesOrder.received_amount), 0))
|
||||
.where((SalesOrder.total_amount - SalesOrder.received_amount) > 0)
|
||||
) or 0
|
||||
payable_total = await db_session.scalar(
|
||||
select(func.coalesce(func.sum(PurchaseOrder.total_amount - PurchaseOrder.paid_amount), 0))
|
||||
.where((PurchaseOrder.total_amount - PurchaseOrder.paid_amount) > 0)
|
||||
) or 0
|
||||
|
||||
now = datetime.now()
|
||||
month_start = datetime(now.year, now.month, 1)
|
||||
|
||||
monthly_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 >= month_start)
|
||||
) or 0
|
||||
monthly_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 >= 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=Decimal(str(receivable_total)),
|
||||
payable_total=Decimal(str(payable_total)),
|
||||
monthly_receipt_total=Decimal(str(monthly_receipt_total)),
|
||||
monthly_payment_total=Decimal(str(monthly_payment_total)),
|
||||
selected_year=selected_year,
|
||||
selected_quarter=selected_quarter,
|
||||
period_label=period_label,
|
||||
period_receipt_total=Decimal(str(period_receipt_total)),
|
||||
period_payment_total=Decimal(str(period_payment_total)),
|
||||
overdue_receivable_count=0,
|
||||
overdue_payable_count=0,
|
||||
)
|
||||
return await finance_service.get_summary(db_session, year, quarter)
|
||||
|
||||
|
||||
@router.get("/partner-statement/{partner_type}", response_model=FinancePartnerStatementResponse)
|
||||
@@ -452,163 +108,7 @@ async def get_partner_statement(
|
||||
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 = Decimal(str(order.total_amount or 0))
|
||||
settled_amount = Decimal(str(order.received_amount or 0))
|
||||
outstanding = max(total_amount - settled_amount, Decimal("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"] += Decimal(str(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 = Decimal(str(order.total_amount or 0))
|
||||
settled_amount = Decimal(str(order.paid_amount or 0))
|
||||
outstanding = max(total_amount - settled_amount, Decimal("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"] += Decimal(str(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=Decimal(str(item["order_total"])),
|
||||
settled_total=Decimal(str(item["settled_total"])),
|
||||
transaction_total=Decimal(str(item["transaction_total"])),
|
||||
outstanding_total=Decimal(str(item["outstanding_total"])),
|
||||
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=Decimal(str(sum(item.order_total for item in items))),
|
||||
settled_total=Decimal(str(sum(item.settled_total for item in items))),
|
||||
transaction_total=Decimal(str(sum(item.transaction_total for item in items))),
|
||||
outstanding_total=Decimal(str(sum(item.outstanding_total for item in items))),
|
||||
items=items,
|
||||
)
|
||||
return await finance_service.get_partner_statement(db_session, partner_type, year, quarter)
|
||||
|
||||
|
||||
@router.get("/partner-product-statement/{partner_type}", response_model=FinancePartnerProductStatementResponse)
|
||||
@@ -620,132 +120,4 @@ async def get_partner_product_statement(
|
||||
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 = Decimal(str(item.amount or 0))
|
||||
order_total = Decimal(str(order.total_amount or 0))
|
||||
order_settled = max(Decimal(str(order.received_amount or 0)), Decimal("0"))
|
||||
ratio = (item_amount / order_total) if order_total > Decimal("1e-9") else Decimal("0")
|
||||
item_settled = min(item_amount, order_settled * ratio)
|
||||
item_outstanding = max(item_amount - item_settled, Decimal("0"))
|
||||
|
||||
stat["order_ids"].add(order.id)
|
||||
stat["order_quantity"] += Decimal(str(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 = Decimal(str(item.amount or 0))
|
||||
order_total = Decimal(str(order.total_amount or 0))
|
||||
order_settled = max(Decimal(str(order.paid_amount or 0)), Decimal("0"))
|
||||
ratio = (item_amount / order_total) if order_total > Decimal("1e-9") else Decimal("0")
|
||||
item_settled = min(item_amount, order_settled * ratio)
|
||||
item_outstanding = max(item_amount - item_settled, Decimal("0"))
|
||||
|
||||
stat["order_ids"].add(order.id)
|
||||
stat["order_quantity"] += Decimal(str(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=Decimal(str(item["order_quantity"])),
|
||||
order_amount=Decimal(str(item["order_amount"])),
|
||||
settled_amount=Decimal(str(item["settled_amount"])),
|
||||
outstanding_amount=Decimal(str(item["outstanding_amount"])),
|
||||
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=Decimal(str(sum(item.order_amount for item in items))),
|
||||
settled_amount_total=Decimal(str(sum(item.settled_amount for item in items))),
|
||||
outstanding_amount_total=Decimal(str(sum(item.outstanding_amount for item in items))),
|
||||
items=items,
|
||||
)
|
||||
|
||||
return await finance_service.get_partner_product_statement(db_session, partner_type, partner_id, year, quarter)
|
||||
|
||||
@@ -16,7 +16,7 @@ from typing import Optional, List
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.database import User, Product, Warehouse, Inventory
|
||||
from .schemas import InventoryResponse, InventoryCreate, InventoryUpdate, PaginatedResponse
|
||||
from ..schemas import InventoryResponse, InventoryCreate, InventoryUpdate, PaginatedResponse
|
||||
|
||||
router = APIRouter(prefix="/inventory", tags=["库存管理"])
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ from typing import Optional, List
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.database import User, Product, MaterialPriceHistory, MaterialSupplier, Supplier
|
||||
from .schemas import (
|
||||
from ..schemas import (
|
||||
MaterialPriceHistoryCreate,
|
||||
MaterialPriceHistoryResponse,
|
||||
MaterialSupplierCreate,
|
||||
|
||||
@@ -18,7 +18,7 @@ from decimal import Decimal
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user, get_current_admin_user
|
||||
from shared.models.database import User, Product, ProductMaterial
|
||||
from .schemas import (
|
||||
from ..schemas import (
|
||||
ProductCreate,
|
||||
ProductResponse,
|
||||
ProductBOMUpdate,
|
||||
|
||||
@@ -26,7 +26,7 @@ from shared.models.database import (
|
||||
PurchaseOrder,
|
||||
PurchaseOrderItem
|
||||
)
|
||||
from .schemas import (
|
||||
from ..schemas import (
|
||||
PurchaseOrderCreate,
|
||||
PurchaseOrderResponse,
|
||||
PurchaseOrderDetailResponse,
|
||||
@@ -34,7 +34,7 @@ from .schemas import (
|
||||
PurchaseOrderReceiveRequest,
|
||||
PaginatedResponse,
|
||||
)
|
||||
from .utils import generate_order_no
|
||||
from ..utils import generate_order_no
|
||||
|
||||
router = APIRouter(prefix="/purchase-orders", tags=["采购订单"])
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ from shared.models.database import (
|
||||
SalesOrder,
|
||||
SalesOrderItem
|
||||
)
|
||||
from .schemas import (
|
||||
from ..schemas import (
|
||||
SalesOrderCreate,
|
||||
SalesOrderResponse,
|
||||
SalesOrderDetailResponse,
|
||||
@@ -41,7 +41,7 @@ from .schemas import (
|
||||
SalesOrderStatusUpdate,
|
||||
PaginatedResponse,
|
||||
)
|
||||
from .utils import generate_order_no
|
||||
from ..utils import generate_order_no
|
||||
|
||||
router = APIRouter(prefix="/sales-orders", tags=["销售订单"])
|
||||
VALID_ORDER_STATUSES = {"manufacturing", "delivered", "paid", "cancelled"}
|
||||
|
||||
@@ -17,8 +17,8 @@ from typing import Optional, List
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.database import User, Product, Warehouse, Inventory, StockMovement
|
||||
from .schemas import StockMovementCreate, StockMovementResponse, PaginatedResponse
|
||||
from .utils import generate_order_no
|
||||
from ..schemas import StockMovementCreate, StockMovementResponse, PaginatedResponse
|
||||
from ..utils import generate_order_no
|
||||
|
||||
router = APIRouter(prefix="/stock-movements", tags=["库存变动"])
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ from datetime import datetime
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user, get_current_admin_user
|
||||
from shared.models.database import User, Supplier
|
||||
from .schemas import SupplierCreate, SupplierResponse
|
||||
from ..schemas import SupplierCreate, SupplierResponse
|
||||
|
||||
router = APIRouter(prefix="/suppliers", tags=["供应商管理"])
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ from datetime import datetime
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.database import User, Warehouse
|
||||
from .schemas import WarehouseCreate, WarehouseResponse
|
||||
from ..schemas import WarehouseCreate, WarehouseResponse
|
||||
|
||||
router = APIRouter(prefix="/warehouses", tags=["仓库管理"])
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ class FinanceTransactionResponse(BaseModel):
|
||||
txn_type: TxnType
|
||||
partner_type: PartnerType
|
||||
partner_id: int
|
||||
partner_name: Optional[str] = None
|
||||
amount: Decimal
|
||||
txn_date: datetime
|
||||
method: str
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""进销存业务服务层
|
||||
|
||||
将原 api/*_routes.py 中的业务编排(事务、库存增减、流水写入、对账)下沉到此层,
|
||||
路由层只做参数校验与响应组装。每个业务域一个 service。
|
||||
"""
|
||||
@@ -0,0 +1,742 @@
|
||||
"""财务业务服务层
|
||||
|
||||
将原 finance_routes 中的业务编排(收款/付款/核销/作废/对账)下沉到此,
|
||||
路由层只做参数校验与响应组装。
|
||||
"""
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
from typing import Optional, List, Dict, Tuple
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from shared.models.database import (
|
||||
User,
|
||||
Customer,
|
||||
Supplier,
|
||||
Product,
|
||||
SalesOrder,
|
||||
SalesOrderItem,
|
||||
PurchaseOrder,
|
||||
PurchaseOrderItem,
|
||||
FinanceTransaction,
|
||||
FinanceAllocation,
|
||||
)
|
||||
from ..schemas import (
|
||||
ReceiptCreate,
|
||||
PaymentCreate,
|
||||
FinanceTransactionResponse,
|
||||
FinanceSummaryResponse,
|
||||
ReceivableItemResponse,
|
||||
PayableItemResponse,
|
||||
FinancePartnerStatementResponse,
|
||||
PartnerStatementItemResponse,
|
||||
FinancePartnerProductStatementResponse,
|
||||
PartnerProductStatementItemResponse,
|
||||
PaginatedResponse,
|
||||
)
|
||||
from ..utils import generate_order_no
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class FinanceService:
|
||||
"""财务业务服务:收款/付款/核销/作废/对账"""
|
||||
|
||||
@staticmethod
|
||||
def _build_transaction_response(txn: FinanceTransaction, partner_name: Optional[str] = None) -> FinanceTransactionResponse:
|
||||
allocations = [
|
||||
{
|
||||
"id": item.id,
|
||||
"order_type": item.order_type,
|
||||
"order_id": item.order_id,
|
||||
"allocated_amount": item.allocated_amount,
|
||||
}
|
||||
for item in txn.allocations
|
||||
]
|
||||
return FinanceTransactionResponse(
|
||||
id=txn.id,
|
||||
txn_no=txn.txn_no,
|
||||
txn_type=txn.txn_type,
|
||||
partner_type=txn.partner_type,
|
||||
partner_id=txn.partner_id,
|
||||
partner_name=partner_name,
|
||||
amount=txn.amount,
|
||||
txn_date=txn.txn_date,
|
||||
method=txn.method,
|
||||
account_name=txn.account_name,
|
||||
status=txn.status,
|
||||
remark=txn.remark,
|
||||
created_at=txn.created_at,
|
||||
allocations=allocations,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _validate_allocation_total(transaction_amount: float, allocation_amounts: List[float]):
|
||||
allocated_total = sum(allocation_amounts)
|
||||
if allocated_total - transaction_amount > 1e-6:
|
||||
raise HTTPException(status_code=400, detail="核销总额不能大于单据金额")
|
||||
|
||||
@staticmethod
|
||||
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
|
||||
|
||||
@staticmethod
|
||||
async def create_receipt(db_session: AsyncSession, payload: ReceiptCreate, current_user: User) -> FinanceTransactionResponse:
|
||||
customer_result = await db_session.execute(
|
||||
select(Customer).where(Customer.id == payload.customer_id, Customer.is_active == True)
|
||||
)
|
||||
customer = customer_result.scalar_one_or_none()
|
||||
if not customer:
|
||||
raise HTTPException(status_code=404, detail="客户不存在")
|
||||
|
||||
partner_name = customer.name
|
||||
FinanceService._validate_allocation_total(payload.amount, [item.allocated_amount for item in payload.allocations])
|
||||
|
||||
txn = FinanceTransaction(
|
||||
txn_no=generate_order_no("RC"),
|
||||
txn_type="receipt",
|
||||
partner_type="customer",
|
||||
partner_id=payload.customer_id,
|
||||
amount=payload.amount,
|
||||
txn_date=payload.txn_date or datetime.now(),
|
||||
method=payload.method,
|
||||
account_name=payload.account_name,
|
||||
status="confirmed",
|
||||
remark=payload.remark,
|
||||
operator_id=current_user.id,
|
||||
)
|
||||
db_session.add(txn)
|
||||
await db_session.flush()
|
||||
|
||||
for allocation in payload.allocations:
|
||||
if allocation.order_type != "sales":
|
||||
raise HTTPException(status_code=400, detail="收款单只允许核销销售订单")
|
||||
|
||||
order_result = await db_session.execute(
|
||||
select(SalesOrder).where(SalesOrder.id == allocation.order_id, SalesOrder.customer_id == payload.customer_id)
|
||||
)
|
||||
sales_order = order_result.scalar_one_or_none()
|
||||
if not sales_order:
|
||||
raise HTTPException(status_code=404, detail=f"销售订单不存在: {allocation.order_id}")
|
||||
|
||||
remaining = (sales_order.total_amount or 0) - (sales_order.received_amount or 0)
|
||||
if allocation.allocated_amount - remaining > 1e-6:
|
||||
raise HTTPException(status_code=400, detail=f"销售订单核销超额: {sales_order.order_no}")
|
||||
|
||||
db_session.add(
|
||||
FinanceAllocation(
|
||||
transaction_id=txn.id,
|
||||
order_type="sales",
|
||||
order_id=sales_order.id,
|
||||
allocated_amount=allocation.allocated_amount,
|
||||
)
|
||||
)
|
||||
sales_order.received_amount = (sales_order.received_amount or 0) + allocation.allocated_amount
|
||||
|
||||
await db_session.commit()
|
||||
result = await db_session.execute(
|
||||
select(FinanceTransaction)
|
||||
.options(selectinload(FinanceTransaction.allocations))
|
||||
.where(FinanceTransaction.id == txn.id)
|
||||
)
|
||||
created = result.scalar_one()
|
||||
return FinanceService._build_transaction_response(created, partner_name=partner_name)
|
||||
|
||||
@staticmethod
|
||||
async def create_payment(db_session: AsyncSession, payload: PaymentCreate, current_user: User) -> FinanceTransactionResponse:
|
||||
supplier_result = await db_session.execute(
|
||||
select(Supplier).where(Supplier.id == payload.supplier_id, Supplier.is_active == True)
|
||||
)
|
||||
supplier = supplier_result.scalar_one_or_none()
|
||||
if not supplier:
|
||||
raise HTTPException(status_code=404, detail="供应商不存在")
|
||||
|
||||
partner_name = supplier.name
|
||||
FinanceService._validate_allocation_total(payload.amount, [item.allocated_amount for item in payload.allocations])
|
||||
|
||||
txn = FinanceTransaction(
|
||||
txn_no=generate_order_no("PY"),
|
||||
txn_type="payment",
|
||||
partner_type="supplier",
|
||||
partner_id=payload.supplier_id,
|
||||
amount=payload.amount,
|
||||
txn_date=payload.txn_date or datetime.now(),
|
||||
method=payload.method,
|
||||
account_name=payload.account_name,
|
||||
status="confirmed",
|
||||
remark=payload.remark,
|
||||
operator_id=current_user.id,
|
||||
)
|
||||
db_session.add(txn)
|
||||
await db_session.flush()
|
||||
|
||||
for allocation in payload.allocations:
|
||||
if allocation.order_type != "purchase":
|
||||
raise HTTPException(status_code=400, detail="付款单只允许核销采购订单")
|
||||
|
||||
order_result = await db_session.execute(
|
||||
select(PurchaseOrder).where(PurchaseOrder.id == allocation.order_id, PurchaseOrder.supplier_id == payload.supplier_id)
|
||||
)
|
||||
purchase_order = order_result.scalar_one_or_none()
|
||||
if not purchase_order:
|
||||
raise HTTPException(status_code=404, detail=f"采购订单不存在: {allocation.order_id}")
|
||||
|
||||
remaining = (purchase_order.total_amount or 0) - (purchase_order.paid_amount or 0)
|
||||
if allocation.allocated_amount - remaining > 1e-6:
|
||||
raise HTTPException(status_code=400, detail=f"采购订单核销超额: {purchase_order.order_no}")
|
||||
|
||||
db_session.add(
|
||||
FinanceAllocation(
|
||||
transaction_id=txn.id,
|
||||
order_type="purchase",
|
||||
order_id=purchase_order.id,
|
||||
allocated_amount=allocation.allocated_amount,
|
||||
)
|
||||
)
|
||||
purchase_order.paid_amount = (purchase_order.paid_amount or 0) + allocation.allocated_amount
|
||||
|
||||
await db_session.commit()
|
||||
result = await db_session.execute(
|
||||
select(FinanceTransaction)
|
||||
.options(selectinload(FinanceTransaction.allocations))
|
||||
.where(FinanceTransaction.id == txn.id)
|
||||
)
|
||||
created = result.scalar_one()
|
||||
return FinanceService._build_transaction_response(created, partner_name=partner_name)
|
||||
|
||||
@staticmethod
|
||||
async def list_transactions(
|
||||
db_session: AsyncSession,
|
||||
txn_type: Optional[str],
|
||||
status: Optional[str],
|
||||
year: Optional[int],
|
||||
quarter: Optional[int],
|
||||
skip: int,
|
||||
limit: int,
|
||||
) -> PaginatedResponse:
|
||||
base_query = (
|
||||
select(FinanceTransaction)
|
||||
.options(selectinload(FinanceTransaction.allocations))
|
||||
.order_by(FinanceTransaction.created_at.desc())
|
||||
)
|
||||
if txn_type:
|
||||
base_query = base_query.where(FinanceTransaction.txn_type == txn_type)
|
||||
if status:
|
||||
base_query = base_query.where(FinanceTransaction.status == status)
|
||||
if year is not None or quarter is not None:
|
||||
_, _, _, period_start, period_end = FinanceService._resolve_period_scope(year, quarter)
|
||||
base_query = base_query.where(FinanceTransaction.txn_date >= period_start).where(FinanceTransaction.txn_date < period_end)
|
||||
|
||||
count_query = select(func.count()).select_from(base_query.subquery())
|
||||
total = await db_session.scalar(count_query) or 0
|
||||
|
||||
query = base_query.offset(skip).limit(limit)
|
||||
result = await db_session.execute(query)
|
||||
rows = result.scalars().all()
|
||||
|
||||
# 批量查询往来方名称(交易记录只存 partner_id,需关联客户/供应商取名称)
|
||||
partner_names: Dict[int, str] = {}
|
||||
customer_ids = {t.partner_id for t in rows if t.partner_type == "customer"}
|
||||
supplier_ids = {t.partner_id for t in rows if t.partner_type == "supplier"}
|
||||
if customer_ids:
|
||||
cr = await db_session.execute(select(Customer.id, Customer.name).where(Customer.id.in_(customer_ids)))
|
||||
partner_names.update({r[0]: r[1] for r in cr.all()})
|
||||
if supplier_ids:
|
||||
sr = await db_session.execute(select(Supplier.id, Supplier.name).where(Supplier.id.in_(supplier_ids)))
|
||||
partner_names.update({r[0]: r[1] for r in sr.all()})
|
||||
|
||||
return PaginatedResponse(
|
||||
items=[FinanceService._build_transaction_response(item, partner_name=partner_names.get(item.partner_id)) for item in rows],
|
||||
total=total, skip=skip, limit=limit
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def void_transaction(db_session: AsyncSession, transaction_id: int, current_user: User) -> dict:
|
||||
result = await db_session.execute(
|
||||
select(FinanceTransaction)
|
||||
.options(selectinload(FinanceTransaction.allocations))
|
||||
.where(FinanceTransaction.id == transaction_id)
|
||||
)
|
||||
txn = result.scalar_one_or_none()
|
||||
if not txn:
|
||||
raise HTTPException(status_code=404, detail="财务单据不存在")
|
||||
if txn.status == "voided":
|
||||
return {"message": "单据已作废"}
|
||||
|
||||
for allocation in txn.allocations:
|
||||
if allocation.order_type == "sales":
|
||||
sales_result = await db_session.execute(select(SalesOrder).where(SalesOrder.id == allocation.order_id))
|
||||
sales_order = sales_result.scalar_one_or_none()
|
||||
if sales_order:
|
||||
sales_order.received_amount = max((sales_order.received_amount or 0) - allocation.allocated_amount, 0)
|
||||
elif allocation.order_type == "purchase":
|
||||
purchase_result = await db_session.execute(select(PurchaseOrder).where(PurchaseOrder.id == allocation.order_id))
|
||||
purchase_order = purchase_result.scalar_one_or_none()
|
||||
if purchase_order:
|
||||
purchase_order.paid_amount = max((purchase_order.paid_amount or 0) - allocation.allocated_amount, 0)
|
||||
|
||||
txn.status = "voided"
|
||||
await db_session.commit()
|
||||
logger.warning(
|
||||
"财务单据已作废: txn_no=%s txn_type=%s amount=%s operator_id=%s",
|
||||
txn.txn_no, txn.txn_type, txn.amount, current_user.id
|
||||
)
|
||||
return {"message": "单据已作废"}
|
||||
|
||||
@staticmethod
|
||||
async def list_receivables(
|
||||
db_session: AsyncSession,
|
||||
year: Optional[int],
|
||||
quarter: Optional[int],
|
||||
skip: int,
|
||||
limit: int,
|
||||
) -> List[ReceivableItemResponse]:
|
||||
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())
|
||||
)
|
||||
if year is not None or quarter is not None:
|
||||
_, _, _, period_start, period_end = FinanceService._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)
|
||||
rows.append(
|
||||
ReceivableItemResponse(
|
||||
order_id=order.id,
|
||||
order_no=order.order_no,
|
||||
customer_id=customer.id,
|
||||
customer_name=customer.name,
|
||||
order_date=order.order_date,
|
||||
total_amount=order.total_amount or 0,
|
||||
received_amount=order.received_amount or 0,
|
||||
receivable_amount=receivable_amount,
|
||||
status=order.status,
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
@staticmethod
|
||||
async def list_payables(
|
||||
db_session: AsyncSession,
|
||||
year: Optional[int],
|
||||
quarter: Optional[int],
|
||||
skip: int,
|
||||
limit: int,
|
||||
) -> List[PayableItemResponse]:
|
||||
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())
|
||||
)
|
||||
if year is not None or quarter is not None:
|
||||
_, _, _, period_start, period_end = FinanceService._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)
|
||||
rows.append(
|
||||
PayableItemResponse(
|
||||
order_id=order.id,
|
||||
order_no=order.order_no,
|
||||
supplier_id=supplier.id,
|
||||
supplier_name=supplier.name,
|
||||
order_date=order.order_date,
|
||||
total_amount=order.total_amount or 0,
|
||||
paid_amount=order.paid_amount or 0,
|
||||
payable_amount=payable_amount,
|
||||
status=order.status,
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
@staticmethod
|
||||
async def get_summary(db_session: AsyncSession, year: Optional[int], quarter: Optional[int]) -> FinanceSummaryResponse:
|
||||
receivable_total = await db_session.scalar(
|
||||
select(func.coalesce(func.sum(SalesOrder.total_amount - SalesOrder.received_amount), 0))
|
||||
.where((SalesOrder.total_amount - SalesOrder.received_amount) > 0)
|
||||
) or 0
|
||||
payable_total = await db_session.scalar(
|
||||
select(func.coalesce(func.sum(PurchaseOrder.total_amount - PurchaseOrder.paid_amount), 0))
|
||||
.where((PurchaseOrder.total_amount - PurchaseOrder.paid_amount) > 0)
|
||||
) or 0
|
||||
|
||||
now = datetime.now()
|
||||
month_start = datetime(now.year, now.month, 1)
|
||||
|
||||
monthly_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 >= month_start)
|
||||
) or 0
|
||||
monthly_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 >= month_start)
|
||||
) or 0
|
||||
|
||||
selected_year, selected_quarter, period_label, period_start, period_end = FinanceService._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=Decimal(str(receivable_total)),
|
||||
payable_total=Decimal(str(payable_total)),
|
||||
monthly_receipt_total=Decimal(str(monthly_receipt_total)),
|
||||
monthly_payment_total=Decimal(str(monthly_payment_total)),
|
||||
selected_year=selected_year,
|
||||
selected_quarter=selected_quarter,
|
||||
period_label=period_label,
|
||||
period_receipt_total=Decimal(str(period_receipt_total)),
|
||||
period_payment_total=Decimal(str(period_payment_total)),
|
||||
overdue_receivable_count=0,
|
||||
overdue_payable_count=0,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def get_partner_statement(
|
||||
db_session: AsyncSession,
|
||||
partner_type: str,
|
||||
year: Optional[int],
|
||||
quarter: Optional[int],
|
||||
) -> FinancePartnerStatementResponse:
|
||||
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 = FinanceService._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 = Decimal(str(order.total_amount or 0))
|
||||
settled_amount = Decimal(str(order.received_amount or 0))
|
||||
outstanding = max(total_amount - settled_amount, Decimal("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"] += Decimal(str(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 = Decimal(str(order.total_amount or 0))
|
||||
settled_amount = Decimal(str(order.paid_amount or 0))
|
||||
outstanding = max(total_amount - settled_amount, Decimal("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"] += Decimal(str(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=Decimal(str(item["order_total"])),
|
||||
settled_total=Decimal(str(item["settled_total"])),
|
||||
transaction_total=Decimal(str(item["transaction_total"])),
|
||||
outstanding_total=Decimal(str(item["outstanding_total"])),
|
||||
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=Decimal(str(sum(item.order_total for item in items))),
|
||||
settled_total=Decimal(str(sum(item.settled_total for item in items))),
|
||||
transaction_total=Decimal(str(sum(item.transaction_total for item in items))),
|
||||
outstanding_total=Decimal(str(sum(item.outstanding_total for item in items))),
|
||||
items=items,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def get_partner_product_statement(
|
||||
db_session: AsyncSession,
|
||||
partner_type: str,
|
||||
partner_id: Optional[int],
|
||||
year: Optional[int],
|
||||
quarter: Optional[int],
|
||||
) -> FinancePartnerProductStatementResponse:
|
||||
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 = FinanceService._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 = Decimal(str(item.amount or 0))
|
||||
order_total = Decimal(str(order.total_amount or 0))
|
||||
order_settled = max(Decimal(str(order.received_amount or 0)), Decimal("0"))
|
||||
ratio = (item_amount / order_total) if order_total > Decimal("1e-9") else Decimal("0")
|
||||
item_settled = min(item_amount, order_settled * ratio)
|
||||
item_outstanding = max(item_amount - item_settled, Decimal("0"))
|
||||
|
||||
stat["order_ids"].add(order.id)
|
||||
stat["order_quantity"] += Decimal(str(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 = Decimal(str(item.amount or 0))
|
||||
order_total = Decimal(str(order.total_amount or 0))
|
||||
order_settled = max(Decimal(str(order.paid_amount or 0)), Decimal("0"))
|
||||
ratio = (item_amount / order_total) if order_total > Decimal("1e-9") else Decimal("0")
|
||||
item_settled = min(item_amount, order_settled * ratio)
|
||||
item_outstanding = max(item_amount - item_settled, Decimal("0"))
|
||||
|
||||
stat["order_ids"].add(order.id)
|
||||
stat["order_quantity"] += Decimal(str(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=Decimal(str(item["order_quantity"])),
|
||||
order_amount=Decimal(str(item["order_amount"])),
|
||||
settled_amount=Decimal(str(item["settled_amount"])),
|
||||
outstanding_amount=Decimal(str(item["outstanding_amount"])),
|
||||
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=Decimal(str(sum(item.order_amount for item in items))),
|
||||
settled_amount_total=Decimal(str(sum(item.settled_amount for item in items))),
|
||||
outstanding_amount_total=Decimal(str(sum(item.outstanding_amount for item in items))),
|
||||
items=items,
|
||||
)
|
||||
|
||||
|
||||
finance_service = FinanceService()
|
||||
@@ -0,0 +1,21 @@
|
||||
"""进销存工具函数模块
|
||||
|
||||
提供进销存系统通用的工具函数。置于 inventory 顶层(非 api 下),
|
||||
以便 services 层与 api 层共用,避免 services -> api 的循环导入。
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
import secrets
|
||||
|
||||
|
||||
def generate_order_no(prefix: str) -> str:
|
||||
"""生成订单编号
|
||||
|
||||
Args:
|
||||
prefix: 订单类型前缀,如 PO(采购订单)、SO(销售订单)、SM(库存变动)
|
||||
|
||||
Returns:
|
||||
格式为 {prefix}{YYYYMMDDHHMMSS}{8位随机字符} 的订单编号
|
||||
"""
|
||||
date_str = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
|
||||
random_str = secrets.token_hex(4).upper()
|
||||
return f"{prefix}{date_str}{random_str}"
|
||||
@@ -26,3 +26,4 @@ _safe_include("moldinsight.api.history_router", "历史")
|
||||
_safe_include("moldinsight.api.debug_router", "调试")
|
||||
_safe_include("moldinsight.api.cam_router", "CAM")
|
||||
_safe_include("moldinsight.api.advanced_router", "高级")
|
||||
_safe_include("moldinsight.api.aluminum_price_routes", "铝价")
|
||||
|
||||
@@ -78,7 +78,7 @@ class GeometryAnalyzer:
|
||||
"""检测模具特征 — 独立检测并行执行"""
|
||||
features: List[Dict[str, Any]] = []
|
||||
|
||||
with ThreadPoolExecutor(max_workers=4, thread_name_prefix="feat") as pool:
|
||||
with ThreadPoolExecutor(max_workers=1, thread_name_prefix="feat") as pool:
|
||||
futures = {
|
||||
pool.submit(self._detect_wall_features, geometry_data, shape): "wall",
|
||||
pool.submit(self._detect_rib_features, geometry_data, shape): "rib",
|
||||
|
||||
@@ -336,7 +336,7 @@ class LLMService:
|
||||
volume=f"{analysis_result.get('geometry_data', {}).get('volume', 0):.1f} mm³",
|
||||
surface_area=f"{analysis_result.get('geometry_data', {}).get('surface_area', 0):.1f} mm²",
|
||||
bbox=json.dumps(analysis_result.get("geometry_data", {}).get("bounding_box", {}), ensure_ascii=False),
|
||||
features=trimmed or "无特征检测数据",
|
||||
features=features or "无特征检测数据",
|
||||
quality_metrics=json.dumps(analysis_result.get("quality_metrics", {}), ensure_ascii=False, indent=2),
|
||||
schemes=schemes_text or "无分模方案数据",
|
||||
mold_material=mfg.get("mold_material", "自动选择"),
|
||||
|
||||
@@ -28,6 +28,7 @@ class UserResponse(BaseModel):
|
||||
email: str
|
||||
full_name: Optional[str]
|
||||
is_active: bool
|
||||
is_superuser: bool = False
|
||||
roles: List[str]
|
||||
|
||||
class Config:
|
||||
@@ -102,6 +103,19 @@ def check_admin(user: User) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _build_user_response(user: User) -> UserResponse:
|
||||
"""统一构造用户响应,确保 is_superuser 等字段一致"""
|
||||
return UserResponse(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
full_name=user.full_name,
|
||||
is_active=user.is_active,
|
||||
is_superuser=user.is_superuser,
|
||||
roles=[r.code for r in user.roles],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
@@ -123,14 +137,7 @@ async def login(
|
||||
return Token(
|
||||
access_token=access_token,
|
||||
token_type="bearer",
|
||||
user=UserResponse(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
full_name=user.full_name,
|
||||
is_active=user.is_active,
|
||||
roles=[r.code for r in user.roles]
|
||||
)
|
||||
user=_build_user_response(user)
|
||||
)
|
||||
|
||||
|
||||
@@ -154,14 +161,7 @@ async def login_json(
|
||||
return Token(
|
||||
access_token=access_token,
|
||||
token_type="bearer",
|
||||
user=UserResponse(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
full_name=user.full_name,
|
||||
is_active=user.is_active,
|
||||
roles=[r.code for r in user.roles]
|
||||
)
|
||||
user=_build_user_response(user)
|
||||
)
|
||||
|
||||
|
||||
@@ -169,14 +169,7 @@ async def login_json(
|
||||
async def get_current_user_info(
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
return UserResponse(
|
||||
id=current_user.id,
|
||||
username=current_user.username,
|
||||
email=current_user.email,
|
||||
full_name=current_user.full_name,
|
||||
is_active=current_user.is_active,
|
||||
roles=[r.code for r in current_user.roles]
|
||||
)
|
||||
return _build_user_response(current_user)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
@@ -195,14 +188,7 @@ async def list_users(
|
||||
)
|
||||
users = result.scalars().all()
|
||||
return [
|
||||
UserResponse(
|
||||
id=u.id,
|
||||
username=u.username,
|
||||
email=u.email,
|
||||
full_name=u.full_name,
|
||||
is_active=u.is_active,
|
||||
roles=[r.code for r in u.roles]
|
||||
) for u in users
|
||||
_build_user_response(u) for u in users
|
||||
]
|
||||
|
||||
|
||||
@@ -245,14 +231,7 @@ async def create_user(
|
||||
|
||||
logger.info(f"管理员 {current_user.username} 创建了用户 {user.username}")
|
||||
|
||||
return UserResponse(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
full_name=user.full_name,
|
||||
is_active=user.is_active,
|
||||
roles=[r.code for r in user.roles]
|
||||
)
|
||||
return _build_user_response(user)
|
||||
|
||||
|
||||
@router.put("/users/{user_id}", response_model=UserResponse)
|
||||
@@ -292,14 +271,7 @@ async def update_user(
|
||||
|
||||
logger.info(f"管理员 {current_user.username} 更新了用户 {user.username}")
|
||||
|
||||
return UserResponse(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
full_name=user.full_name,
|
||||
is_active=user.is_active,
|
||||
roles=[r.code for r in user.roles]
|
||||
)
|
||||
return _build_user_response(user)
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}")
|
||||
|
||||
@@ -60,6 +60,18 @@ class RedisTaskManager:
|
||||
self._redis = None
|
||||
self._connected = False
|
||||
|
||||
async def reconnect(self):
|
||||
"""强制重新连接
|
||||
|
||||
用于事件循环会变更的场景(如 Celery 每个任务经 asyncio.run 创建新循环):
|
||||
redis.asyncio 客户端绑定到创建它的循环,旧循环关闭后客户端失效,
|
||||
必须在新循环中重建客户端才能继续使用。
|
||||
"""
|
||||
# 丢弃绑定在旧(已关闭)循环上的客户端,connect() 会重建
|
||||
self._redis = None
|
||||
self._connected = False
|
||||
await self.connect()
|
||||
|
||||
async def disconnect(self):
|
||||
"""断开 Redis 连接"""
|
||||
if self._redis:
|
||||
|
||||
Reference in New Issue
Block a user