This commit is contained in:
2026-05-11 11:35:15 +08:00
parent 5e1d475a22
commit a68b9ca57f
22 changed files with 432 additions and 11341 deletions
+13 -1
View File
@@ -53,4 +53,16 @@ REDIS_DB=0
ADMIN_USERNAME=cjw
ADMIN_PASSWORD=Qqs1996*
ADMIN_EMAIL=792430652@qq.com
ADMIN_FULL_NAME=管理员
ADMIN_FULL_NAME=管理员
# LLM 增强分析配置(可选)
# 启用后自动生成模具设计评审报告和分模方向推荐
# 支持 OpenAI 兼容 API(OpenAI / DeepSeek / vLLM / Ollama 等)
LLM_ENABLED=true
LLM_API_URL=https://api.deepseek.com/v1
LLM_API_KEY=sk-509f968af3e2466bbef8b5949180782c
LLM_MODEL=deepseek-v4-flash
LLM_TIMEOUT=60
LLM_MAX_TOKENS=2000
+10
View File
@@ -51,3 +51,13 @@ ADMIN_FULL_NAME=系统管理员
# 启用后会增加处理时间,默认禁用
ENABLE_FREECAD_VERIFICATION=false
FREECAD_VERIFICATION_TIMEOUT=120
# LLM 增强分析配置(可选)
# 启用后自动生成模具设计评审报告和分模方向推荐
# 支持 OpenAI 兼容 API(OpenAI / DeepSeek / vLLM / Ollama 等)
LLM_ENABLED=false
LLM_API_URL=https://api.openai.com/v1
LLM_API_KEY=sk-your-api-key
LLM_MODEL=gpt-4o-mini
LLM_TIMEOUT=60
LLM_MAX_TOKENS=2000
+57 -66
View File
@@ -1,49 +1,39 @@
# config/settings.py
import os
import urllib.parse
from typing import Dict, Any
from dotenv import load_dotenv
# 加载.env文件
load_dotenv()
class Settings:
"""配置管理器"""
def __init__(self):
# 从环境变量加载配置
self.HOST = os.getenv('HOST', '0.0.0.0')
self.PORT = int(os.getenv('PORT', '8000'))
self.DEBUG = os.getenv('DEBUG', 'false').lower() == 'true'
# 文件处理配置
self.UPLOAD_DIR = os.getenv('UPLOAD_DIR', './uploads')
self.MAX_FILE_SIZE = int(os.getenv('MAX_FILE_SIZE', '104857600'))
self.ALLOWED_EXTENSIONS = os.getenv('ALLOWED_EXTENSIONS', '.stp,.step,.stp.gz')
# 几何处理配置
self.POINTCLOUD_SAMPLE_COUNT = int(os.getenv('POINTCLOUD_SAMPLE_COUNT', '10000'))
self.MESH_QUALITY = os.getenv('MESH_QUALITY', 'high')
self.PARALLEL_PROCESSING = os.getenv('PARALLEL_PROCESSING', 'true').lower() == 'true'
# RustFS 对象存储配置 (S3v4 API)
self.RUSTFS_ENDPOINT = os.getenv('RUSTFS_ENDPOINT') or os.getenv('MINIO_ENDPOINT') or 'http://localhost:8080'
self.RUSTFS_ACCESS_KEY = os.getenv('RUSTFS_ACCESS_KEY') or os.getenv('MINIO_ACCESS_KEY') or 'your-access-key'
self.RUSTFS_SECRET_KEY = os.getenv('RUSTFS_SECRET_KEY') or os.getenv('MINIO_SECRET_KEY') or 'your-secret-key'
self.RUSTFS_TIMEOUT = int(os.getenv('RUSTFS_TIMEOUT', '30'))
# 预签名URL过期时间(秒)
self.RUSTFS_PRESIGNED_URL_EXPIRES = int(os.getenv('RUSTFS_PRESIGNED_URL_EXPIRES', '3600'))
# 数据库配置 - 必须来自环境变量
# 先检查所有配置是否存在
db_host = os.getenv('DB_HOST')
db_port_str = os.getenv('DB_PORT')
db_name = os.getenv('DB_NAME')
db_user = os.getenv('DB_USER')
db_password = os.getenv('DB_PASSWORD')
self.HOST = os.getenv("HOST", "0.0.0.0")
self.PORT = int(os.getenv("PORT", "8000"))
self.DEBUG = os.getenv("DEBUG", "false").lower() == "true"
self.UPLOAD_DIR = os.getenv("UPLOAD_DIR", "./uploads")
self.MAX_FILE_SIZE = int(os.getenv("MAX_FILE_SIZE", "104857600"))
self.ALLOWED_EXTENSIONS = os.getenv("ALLOWED_EXTENSIONS", ".stp,.step,.stp.gz")
self.POINTCLOUD_SAMPLE_COUNT = int(os.getenv("POINTCLOUD_SAMPLE_COUNT", "10000"))
self.MESH_QUALITY = os.getenv("MESH_QUALITY", "high")
self.PARALLEL_PROCESSING = os.getenv("PARALLEL_PROCESSING", "true").lower() == "true"
self.RUSTFS_ENDPOINT = os.getenv("RUSTFS_ENDPOINT") or os.getenv("MINIO_ENDPOINT") or "http://localhost:8080"
self.RUSTFS_ACCESS_KEY = os.getenv("RUSTFS_ACCESS_KEY") or os.getenv("MINIO_ACCESS_KEY") or "your-access-key"
self.RUSTFS_SECRET_KEY = os.getenv("RUSTFS_SECRET_KEY") or os.getenv("MINIO_SECRET_KEY") or "your-secret-key"
self.RUSTFS_TIMEOUT = int(os.getenv("RUSTFS_TIMEOUT", "30"))
self.RUSTFS_PRESIGNED_URL_EXPIRES = int(os.getenv("RUSTFS_PRESIGNED_URL_EXPIRES", "3600"))
db_host = os.getenv("DB_HOST")
db_port_str = os.getenv("DB_PORT")
db_name = os.getenv("DB_NAME")
db_user = os.getenv("DB_USER")
db_password = os.getenv("DB_PASSWORD")
missing_configs = []
if not db_host:
missing_configs.append("DB_HOST")
@@ -55,52 +45,53 @@ class Settings:
missing_configs.append("DB_USER")
if not db_password:
missing_configs.append("DB_PASSWORD")
if missing_configs:
raise ValueError(f"数据库配置缺失,请在.env文件中设置: {', '.join(missing_configs)}")
# 所有配置都存在,进行赋值
self.DB_HOST = db_host
self.DB_PORT = int(db_port_str)
self.DB_NAME = db_name
self.DB_USER = db_user
self.DB_PASSWORD = db_password
# JWT配置
self.SECRET_KEY = os.getenv('SECRET_KEY')
if not self.SECRET_KEY:
raise ValueError("SECRET_KEY 未设置,请在.env文件中配置安全的密钥")
self.ALGORITHM = os.getenv('ALGORITHM', 'HS256')
self.ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv('ACCESS_TOKEN_EXPIRE_MINUTES', '1440'))
# 管理员账户配置
self.ADMIN_USERNAME = os.getenv('ADMIN_USERNAME', 'admin')
self.ADMIN_PASSWORD = os.getenv('ADMIN_PASSWORD')
if not self.ADMIN_PASSWORD:
raise ValueError("ADMIN_PASSWORD 未设置,请在.env文件中配置管理员密码")
self.ADMIN_EMAIL = os.getenv('ADMIN_EMAIL', 'admin@gemold.com')
self.ADMIN_FULL_NAME = os.getenv('ADMIN_FULL_NAME', '系统管理员')
# FreeCAD 验证配置
self.ENABLE_FREECAD_VERIFICATION = os.getenv('ENABLE_FREECAD_VERIFICATION', 'false').lower() == 'true'
self.FREECAD_VERIFICATION_TIMEOUT = int(os.getenv('FREECAD_VERIFICATION_TIMEOUT', '120'))
self.SECRET_KEY = os.getenv("SECRET_KEY")
self.ALGORITHM = os.getenv("ALGORITHM", "HS256")
self.ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "1440"))
self.ADMIN_USERNAME = os.getenv("ADMIN_USERNAME", "admin")
self.ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD")
self.ADMIN_EMAIL = os.getenv("ADMIN_EMAIL", "admin@gemold.com")
self.ADMIN_FULL_NAME = os.getenv("ADMIN_FULL_NAME", "系统管理员")
self.ENABLE_FREECAD_VERIFICATION = os.getenv("ENABLE_FREECAD_VERIFICATION", "false").lower() == "true"
self.FREECAD_VERIFICATION_TIMEOUT = int(os.getenv("FREECAD_VERIFICATION_TIMEOUT", "120"))
# Redis
self.REDIS_HOST = os.getenv("REDIS_HOST", "localhost")
self.REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
self.REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", "")
self.REDIS_DB = int(os.getenv("REDIS_DB", "0"))
# LLM 增强分析配置(可选)
self.LLM_ENABLED = os.getenv("LLM_ENABLED", "false").lower() == "true"
self.LLM_API_URL = os.getenv("LLM_API_URL", "https://api.openai.com/v1")
self.LLM_API_KEY = os.getenv("LLM_API_KEY", "")
self.LLM_MODEL = os.getenv("LLM_MODEL", "gpt-4o-mini")
self.LLM_TIMEOUT = int(os.getenv("LLM_TIMEOUT", "60"))
self.LLM_MAX_TOKENS = int(os.getenv("LLM_MAX_TOKENS", "2000"))
@property
def DATABASE_URL(self) -> str:
"""动态生成数据库连接URL"""
# 安全编码密码
if self.DB_PASSWORD:
safe_password = urllib.parse.quote(self.DB_PASSWORD.encode('utf-8'), safe='')
safe_password = urllib.parse.quote(self.DB_PASSWORD.encode("utf-8"), safe="")
else:
safe_password = ""
return f"postgresql+asyncpg://{self.DB_USER}:{safe_password}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}"
@property
def allowed_extensions_set(self) -> set:
"""将ALLOWED_EXTENSIONS字符串转换为set"""
return set(ext.strip() for ext in self.ALLOWED_EXTENSIONS.split(','))
return set(ext.strip() for ext in self.ALLOWED_EXTENSIONS.split(","))
# 创建全局配置实例
settings = Settings()
-82
View File
@@ -1,82 +0,0 @@
"""
仪表盘路由模块
提供进销存系统的仪表盘统计数据,包括:
- 基础数据统计(产品数、供应商数、客户数、仓库数)
- 库存统计(总库存量、库存总价值)
- 订单统计(待处理采购订单、待处理销售订单)
- 低库存产品预警(库存量低于最小库存的产品列表)
路由前缀: /api/dashboard
"""
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from database.database import get_db_session
from services.auth_service import get_current_active_user
from models.database import (
User, Product, Supplier, Customer, Warehouse,
Inventory, PurchaseOrder, SalesOrder
)
router = APIRouter(prefix="/dashboard", tags=["仪表盘"])
@router.get("")
async def get_dashboard(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
material_count = await db_session.scalar(
select(func.count(Product.id)).where(Product.is_active == True, Product.item_type == "material")
) or 0
finished_product_count = await db_session.scalar(
select(func.count(Product.id)).where(Product.is_active == True, Product.item_type == "finished")
) or 0
supplier_count = await db_session.scalar(select(func.count(Supplier.id)).where(Supplier.is_active == True))
customer_count = await db_session.scalar(select(func.count(Customer.id)).where(Customer.is_active == True))
warehouse_count = await db_session.scalar(select(func.count(Warehouse.id)).where(Warehouse.is_active == True))
total_stock = await db_session.scalar(
select(func.sum(Inventory.quantity))
.join(Product, Inventory.product_id == Product.id)
.where(Product.item_type == "material")
) or 0
total_value = await db_session.scalar(
select(func.sum(Inventory.quantity * Product.cost_price))
.join(Product, Inventory.product_id == Product.id)
.where(Product.item_type == "material")
) or 0
pending_purchase = await db_session.scalar(
select(func.count(PurchaseOrder.id)).where(PurchaseOrder.status == "pending")
)
pending_sales = await db_session.scalar(
select(func.count(SalesOrder.id)).where(SalesOrder.status == "pending")
)
low_stock_products = await db_session.execute(
select(Product, Inventory)
.join(Inventory, Product.id == Inventory.product_id)
.where(Product.item_type == "material")
.where(Inventory.quantity <= Product.min_stock)
.limit(10)
)
low_stock = [
{"id": p.id, "name": p.name, "sku": p.sku, "quantity": i.quantity, "min_stock": p.min_stock}
for p, i in low_stock_products.all()
]
return {
"finished_product_count": finished_product_count,
"material_count": material_count,
"supplier_count": supplier_count,
"customer_count": customer_count,
"warehouse_count": warehouse_count,
"total_stock": total_stock,
"total_value": round(total_value, 2),
"pending_purchase": pending_purchase,
"pending_sales": pending_sales,
"low_stock_products": low_stock
}
-751
View File
@@ -1,751 +0,0 @@
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, Dict, Tuple
from datetime import datetime
from decimal import Decimal
from database.database import get_db_session
from services.auth_service import get_current_active_user
from 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 utils.logger import get_logger
logger = get_logger(__name__)
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)
@router.post("/payments", response_model=FinanceTransactionResponse, status_code=201)
async def create_payment(
payload: PaymentCreate,
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)
@router.get("/transactions", response_model=PaginatedResponse[FinanceTransactionResponse])
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),
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
)
@router.post("/transactions/{transaction_id}/void")
async def void_transaction(
transaction_id: int,
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": "单据已作废"}
@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),
):
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
@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),
):
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
@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),
):
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,
)
@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 = 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,
)
@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 = 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,
)
-198
View File
@@ -1,198 +0,0 @@
"""
库存管理路由模块
提供库存信息的查询功能,包括:
- 库存列表查询(支持分页、仓库筛选、产品筛选、低库存筛选)
- 显示产品库存数量、锁定数量、可用数量等信息
路由前缀: /api/inventory
"""
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, func
from typing import Optional, List
from database.database import get_db_session
from services.auth_service import get_current_active_user
from models.database import User, Product, Warehouse, Inventory
from .schemas import InventoryResponse, InventoryCreate, InventoryUpdate, PaginatedResponse
router = APIRouter(prefix="/inventory", tags=["库存管理"])
@router.get("", response_model=PaginatedResponse[InventoryResponse])
async def list_inventory(
warehouse_id: Optional[int] = None,
product_id: Optional[int] = None,
low_stock: bool = False,
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
base_query = (
select(Inventory, Product, Warehouse)
.join(Product, Inventory.product_id == Product.id)
.join(Warehouse, Inventory.warehouse_id == Warehouse.id)
.where(Product.is_active == True)
.where(Product.item_type == "material")
.where(Warehouse.is_active == True)
)
if warehouse_id:
base_query = base_query.where(Inventory.warehouse_id == warehouse_id)
if product_id:
base_query = base_query.where(Inventory.product_id == product_id)
if low_stock:
base_query = base_query.where(Inventory.quantity <= Product.min_stock)
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)
inventory_list = []
for inv, product, warehouse in result.all():
inventory_list.append(InventoryResponse(
id=inv.id,
product_id=inv.product_id,
product_name=product.name,
product_sku=product.sku,
warehouse_id=inv.warehouse_id,
warehouse_name=warehouse.name,
quantity=inv.quantity,
locked_quantity=inv.locked_quantity,
available_quantity=inv.available_quantity
))
return PaginatedResponse(items=inventory_list, total=total, skip=skip, limit=limit)
@router.post("", response_model=InventoryResponse, status_code=201)
async def create_inventory(
payload: InventoryCreate,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
if payload.quantity < 0 or payload.locked_quantity < 0:
raise HTTPException(status_code=400, detail="库存数量不能为负数")
if payload.locked_quantity > payload.quantity:
raise HTTPException(status_code=400, detail="锁定数量不能大于库存数量")
product_result = await db_session.execute(
select(Product).where(Product.id == payload.product_id, Product.is_active == True)
)
product = product_result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="物料不存在")
if product.item_type != "material":
raise HTTPException(status_code=400, detail="库存仅支持物料")
warehouse_result = await db_session.execute(
select(Warehouse).where(Warehouse.id == payload.warehouse_id, Warehouse.is_active == True)
)
warehouse = warehouse_result.scalar_one_or_none()
if not warehouse:
raise HTTPException(status_code=404, detail="仓库不存在")
exists_result = await db_session.execute(
select(Inventory).where(
Inventory.product_id == payload.product_id,
Inventory.warehouse_id == payload.warehouse_id
)
)
if exists_result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="该仓库已存在该物料库存记录")
inventory = Inventory(
product_id=payload.product_id,
warehouse_id=payload.warehouse_id,
quantity=payload.quantity,
locked_quantity=payload.locked_quantity,
batch_number=payload.batch_number,
location=payload.location
)
db_session.add(inventory)
await db_session.commit()
await db_session.refresh(inventory)
return InventoryResponse(
id=inventory.id,
product_id=product.id,
product_name=product.name,
product_sku=product.sku,
warehouse_id=warehouse.id,
warehouse_name=warehouse.name,
quantity=inventory.quantity,
locked_quantity=inventory.locked_quantity,
available_quantity=inventory.available_quantity
)
@router.put("/{inventory_id}", response_model=InventoryResponse)
async def update_inventory(
inventory_id: int,
payload: InventoryUpdate,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
result = await db_session.execute(
select(Inventory, Product, Warehouse)
.join(Product, Inventory.product_id == Product.id)
.join(Warehouse, Inventory.warehouse_id == Warehouse.id)
.where(Inventory.id == inventory_id)
.where(Product.item_type == "material")
.with_for_update(of=Inventory)
)
row = result.first()
if not row:
raise HTTPException(status_code=404, detail="库存记录不存在")
inventory, product, warehouse = row
if payload.quantity is not None:
if payload.quantity < 0:
raise HTTPException(status_code=400, detail="库存数量不能为负数")
inventory.quantity = payload.quantity
if payload.locked_quantity is not None:
if payload.locked_quantity < 0:
raise HTTPException(status_code=400, detail="锁定数量不能为负数")
inventory.locked_quantity = payload.locked_quantity
if inventory.locked_quantity > inventory.quantity:
raise HTTPException(status_code=400, detail="锁定数量不能大于库存数量")
if payload.batch_number is not None:
inventory.batch_number = payload.batch_number
if payload.location is not None:
inventory.location = payload.location
await db_session.commit()
await db_session.refresh(inventory)
return InventoryResponse(
id=inventory.id,
product_id=product.id,
product_name=product.name,
product_sku=product.sku,
warehouse_id=warehouse.id,
warehouse_name=warehouse.name,
quantity=inventory.quantity,
locked_quantity=inventory.locked_quantity,
available_quantity=inventory.available_quantity
)
@router.delete("/{inventory_id}")
async def delete_inventory(
inventory_id: int,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
result = await db_session.execute(select(Inventory).where(Inventory.id == inventory_id))
inventory = result.scalar_one_or_none()
if not inventory:
raise HTTPException(status_code=404, detail="库存记录不存在")
if inventory.quantity > 0:
raise HTTPException(status_code=400, detail="库存数量不为零,无法删除库存记录")
await db_session.delete(inventory)
await db_session.commit()
return {"message": "库存记录已删除"}
-260
View File
@@ -1,260 +0,0 @@
"""
产品管理路由模块
提供产品信息的增删改查功能,包括:
- 产品列表查询(支持分页、搜索、分类筛选)
- 创建新产品(SKU唯一性校验)
- 更新产品信息
- 删除产品(软删除,需要管理员权限)
路由前缀: /api/products
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, or_, func, delete
from typing import Optional, List, Dict
from decimal import Decimal
from database.database import get_db_session
from services.auth_service import get_current_active_user, get_current_admin_user
from models.database import User, Product, ProductMaterial
from .schemas import (
ProductCreate,
ProductResponse,
ProductBOMUpdate,
ProductBOMResponse,
ProductMaterialItemResponse
)
router = APIRouter(prefix="/products", tags=["产品管理"])
async def _calculate_material_cost_map(db_session: AsyncSession, product_ids: List[int]) -> Dict[int, float]:
if not product_ids:
return {}
result = await db_session.execute(
select(
ProductMaterial.finished_product_id,
func.coalesce(
func.sum(
Product.cost_price * ProductMaterial.quantity
),
0
)
)
.join(Product, ProductMaterial.material_product_id == Product.id)
.where(ProductMaterial.finished_product_id.in_(product_ids))
.group_by(ProductMaterial.finished_product_id)
)
return {row[0]: Decimal(str(row[1] or 0)) for row in result.all()}
def _build_product_response(product: Product, material_cost: Decimal = Decimal("0")) -> ProductResponse:
return ProductResponse(
id=product.id,
sku=product.sku,
name=product.name,
description=product.description,
category=product.category,
unit=product.unit,
item_type=product.item_type,
cost_price=Decimal(str(product.cost_price or 0)),
sale_price=Decimal(str(product.sale_price or 0)),
min_stock=product.min_stock,
max_stock=product.max_stock,
material_cost=Decimal(str(material_cost)).quantize(Decimal("0.0001")),
is_active=product.is_active,
created_at=product.created_at,
)
@router.get("", response_model=List[ProductResponse])
async def list_products(
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
search: Optional[str] = None,
category: Optional[str] = None,
item_type: Optional[str] = None,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
query = select(Product).where(Product.is_active == True)
if search:
query = query.where(or_(Product.name.ilike(f"%{search}%"), Product.sku.ilike(f"%{search}%")))
if category:
query = query.where(Product.category == category)
if item_type:
query = query.where(Product.item_type == item_type)
query = query.offset(skip).limit(limit).order_by(Product.created_at.desc())
result = await db_session.execute(query)
products = result.scalars().all()
finished_product_ids = [p.id for p in products if p.item_type == "finished"]
material_cost_map = await _calculate_material_cost_map(db_session, finished_product_ids)
return [_build_product_response(p, material_cost_map.get(p.id, 0)) for p in products]
@router.post("", response_model=ProductResponse, status_code=201)
async def create_product(
product_data: ProductCreate,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
if product_data.item_type not in ["material", "finished"]:
raise HTTPException(status_code=400, detail="item_type 必须为 material 或 finished")
existing = await db_session.execute(select(Product).where(Product.sku == product_data.sku))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="SKU已存在")
product_dict = product_data.dict()
if product_data.item_type == "finished":
product_dict["min_stock"] = 0
product_dict["max_stock"] = 0
product = Product(**product_dict)
db_session.add(product)
await db_session.commit()
await db_session.refresh(product)
return _build_product_response(product, 0)
@router.put("/{product_id}", response_model=ProductResponse)
async def update_product(
product_id: int,
product_data: ProductCreate,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
result = await db_session.execute(select(Product).where(Product.id == product_id))
product = result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="产品不存在")
if product_data.item_type not in ["material", "finished"]:
raise HTTPException(status_code=400, detail="item_type 必须为 material 或 finished")
product_dict = product_data.dict()
if product_data.item_type == "finished":
product_dict["min_stock"] = 0
product_dict["max_stock"] = 0
for key, value in product_dict.items():
setattr(product, key, value)
await db_session.commit()
await db_session.refresh(product)
material_cost_map = await _calculate_material_cost_map(db_session, [product.id])
return _build_product_response(product, material_cost_map.get(product.id, 0))
@router.delete("/{product_id}")
async def delete_product(
product_id: int,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_admin_user)
):
result = await db_session.execute(select(Product).where(Product.id == product_id))
product = result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="产品不存在")
product.is_active = False
await db_session.commit()
return {"message": "产品已删除"}
@router.get("/{product_id}/materials", response_model=ProductBOMResponse)
async def get_product_bom(
product_id: int,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
product_result = await db_session.execute(
select(Product).where(Product.id == product_id, Product.is_active == True)
)
product = product_result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="产品不存在")
if product.item_type != "finished":
raise HTTPException(status_code=400, detail="仅成品支持配置物料BOM")
bom_result = await db_session.execute(
select(ProductMaterial, Product)
.join(Product, ProductMaterial.material_product_id == Product.id)
.where(ProductMaterial.finished_product_id == product_id)
.order_by(ProductMaterial.id.asc())
)
items: List[ProductMaterialItemResponse] = []
total_material_cost = Decimal("0")
for bom, material in bom_result.all():
line_cost = Decimal(str(material.cost_price or 0)) * Decimal(str(bom.quantity))
total_material_cost += line_cost
items.append(
ProductMaterialItemResponse(
material_id=material.id,
material_sku=material.sku,
material_name=material.name,
quantity=Decimal(str(bom.quantity)),
unit_cost=Decimal(str(material.cost_price or 0)),
line_cost=line_cost,
)
)
return ProductBOMResponse(
product_id=product.id,
product_name=product.name,
total_material_cost=total_material_cost,
items=items,
)
@router.put("/{product_id}/materials", response_model=ProductBOMResponse)
async def replace_product_bom(
product_id: int,
payload: ProductBOMUpdate,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
product_result = await db_session.execute(
select(Product).where(Product.id == product_id, Product.is_active == True)
)
product = product_result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="产品不存在")
if product.item_type != "finished":
raise HTTPException(status_code=400, detail="仅成品支持配置物料BOM")
material_ids = [item.material_id for item in payload.items]
if len(material_ids) != len(set(material_ids)):
raise HTTPException(status_code=400, detail="BOM 物料不允许重复")
if material_ids:
material_result = await db_session.execute(
select(Product).where(Product.id.in_(material_ids), Product.is_active == True)
)
materials = material_result.scalars().all()
material_map = {m.id: m for m in materials}
if len(material_map) != len(material_ids):
raise HTTPException(status_code=400, detail="存在无效物料")
invalid_materials = [m.name for m in materials if m.item_type != "material"]
if invalid_materials:
raise HTTPException(status_code=400, detail=f"以下条目不是物料:{', '.join(invalid_materials)}")
else:
material_map = {}
await db_session.execute(delete(ProductMaterial).where(ProductMaterial.finished_product_id == product_id))
for item in payload.items:
if item.quantity <= 0:
raise HTTPException(status_code=400, detail="物料数量必须大于 0")
db_session.add(
ProductMaterial(
finished_product_id=product_id,
material_product_id=item.material_id,
quantity=item.quantity,
loss_rate=item.loss_rate,
)
)
await db_session.commit()
return await get_product_bom(product_id, db_session, current_user)
-438
View File
@@ -1,438 +0,0 @@
"""
采购订单路由模块
提供采购订单的管理功能,包括:
- 采购订单列表查询(支持分页、状态筛选)
- 创建采购订单(自动生成订单号、计算总金额)
- 采购订单明细管理
路由前缀: /api/purchase-orders
"""
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, update
from typing import Optional, List
from decimal import Decimal
from database.database import get_db_session
from services.auth_service import get_current_active_user
from models.database import (
User,
Supplier,
Product,
Warehouse,
Inventory,
StockMovement,
PurchaseOrder,
PurchaseOrderItem
)
from .schemas import (
PurchaseOrderCreate,
PurchaseOrderResponse,
PurchaseOrderDetailResponse,
PurchaseOrderItemResponse,
PurchaseOrderReceiveRequest,
PaginatedResponse,
)
from .utils import generate_order_no
router = APIRouter(prefix="/purchase-orders", tags=["采购订单"])
def _build_purchase_order_response(order: PurchaseOrder, supplier_name: str) -> PurchaseOrderResponse:
return PurchaseOrderResponse(
id=order.id,
order_no=order.order_no,
supplier_name=supplier_name,
order_date=order.order_date,
expected_date=order.expected_date,
status=order.status,
total_amount=order.total_amount,
paid_amount=order.paid_amount,
remark=order.remark,
created_at=order.created_at,
received_date=order.received_date,
paid_date=order.paid_date
)
async def _get_order_with_supplier(
db_session: AsyncSession,
order_id: int
) -> tuple[PurchaseOrder, Supplier]:
result = await db_session.execute(
select(PurchaseOrder, Supplier)
.join(Supplier, PurchaseOrder.supplier_id == Supplier.id)
.where(PurchaseOrder.id == order_id)
)
row = result.first()
if not row:
raise HTTPException(status_code=404, detail="采购订单不存在")
return row[0], row[1]
async def _build_purchase_order_detail(
db_session: AsyncSession,
order: PurchaseOrder,
supplier_name: str
) -> PurchaseOrderDetailResponse:
item_result = await db_session.execute(
select(PurchaseOrderItem, Product)
.join(Product, PurchaseOrderItem.product_id == Product.id)
.where(PurchaseOrderItem.order_id == order.id)
.order_by(PurchaseOrderItem.id.asc())
)
item_rows = item_result.all()
return PurchaseOrderDetailResponse(
id=order.id,
order_no=order.order_no,
supplier_id=order.supplier_id,
supplier_name=supplier_name,
order_date=order.order_date,
expected_date=order.expected_date,
status=order.status,
total_amount=order.total_amount,
paid_amount=order.paid_amount,
remark=order.remark,
created_at=order.created_at,
received_date=order.received_date,
paid_date=order.paid_date,
items=[
PurchaseOrderItemResponse(
id=item.id,
product_id=item.product_id,
product_sku=product.sku,
product_name=product.name,
quantity=item.quantity,
received_quantity=item.received_quantity,
unit_price=item.unit_price,
amount=item.amount,
remark=item.remark
) for item, product in item_rows
]
)
async def _resolve_receive_warehouse(
db_session: AsyncSession,
warehouse_id: Optional[int]
) -> Warehouse:
if warehouse_id:
result = await db_session.execute(
select(Warehouse).where(Warehouse.id == warehouse_id, Warehouse.is_active == True)
)
warehouse = result.scalar_one_or_none()
if not warehouse:
raise HTTPException(status_code=404, detail="仓库不存在")
return warehouse
result = await db_session.execute(
select(Warehouse).where(Warehouse.is_active == True).order_by(Warehouse.is_default.desc(), Warehouse.id.asc())
)
warehouse = result.scalars().first()
if not warehouse:
raise HTTPException(status_code=400, detail="未配置可用仓库")
return warehouse
async def _apply_order_items(
db_session: AsyncSession,
order: PurchaseOrder,
order_data: PurchaseOrderCreate
) -> Decimal:
total_amount = Decimal("0")
for item_data in order_data.items:
product_result = await db_session.execute(
select(Product).where(Product.id == item_data.product_id, Product.is_active == True)
)
product = product_result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=400, detail=f"物料不存在: {item_data.product_id}")
if product.item_type != "material":
raise HTTPException(status_code=400, detail=f"采购单仅允许物料: {product.name}")
# 使用物料的成本价格作为单价,忽略前端提交的单价
unit_price = product.cost_price or 0
if unit_price <= 0:
raise HTTPException(status_code=400, detail=f"物料 {product.name} 未设置成本价格,请在物料管理界面设置")
try:
item = PurchaseOrderItem(
order_id=order.id,
product_id=item_data.product_id,
quantity=int(item_data.quantity),
unit_price=Decimal(str(unit_price)),
amount=Decimal(str(item_data.quantity)) * Decimal(str(unit_price)),
remark=item_data.remark or None
)
db_session.add(item)
total_amount += item.amount
except Exception as e:
raise HTTPException(status_code=400, detail=f"创建订单明细失败: {str(e)}")
return total_amount
@router.get("", response_model=PaginatedResponse[PurchaseOrderResponse])
async def list_purchase_orders(
status: Optional[str] = None,
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
base_query = (
select(PurchaseOrder, Supplier)
.join(Supplier, PurchaseOrder.supplier_id == Supplier.id)
.order_by(PurchaseOrder.created_at.desc())
)
if status:
base_query = base_query.where(PurchaseOrder.status == status)
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)
orders = []
for order, supplier in result.all():
orders.append(_build_purchase_order_response(order, supplier.name))
return PaginatedResponse(items=orders, total=total, skip=skip, limit=limit)
@router.post("", response_model=PurchaseOrderResponse, status_code=201)
async def create_purchase_order(
order_data: PurchaseOrderCreate,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
order = PurchaseOrder(
order_no=generate_order_no("PO"),
supplier_id=order_data.supplier_id,
expected_date=order_data.expected_date,
remark=order_data.remark,
operator_id=current_user.id,
status="pending"
)
db_session.add(order)
await db_session.flush()
try:
order.total_amount = await _apply_order_items(db_session, order, order_data)
await db_session.commit()
except (HTTPException, Exception):
await db_session.rollback()
raise
await db_session.refresh(order)
supplier_result = await db_session.execute(select(Supplier).where(Supplier.id == order.supplier_id))
supplier = supplier_result.scalar_one_or_none()
if not supplier:
raise HTTPException(status_code=404, detail="供应商不存在")
return _build_purchase_order_response(order, supplier.name)
@router.get("/{order_id}", response_model=PurchaseOrderDetailResponse)
async def get_purchase_order_detail(
order_id: int,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
order, supplier = await _get_order_with_supplier(db_session, order_id)
return await _build_purchase_order_detail(db_session, order, supplier.name)
@router.put("/{order_id}", response_model=PurchaseOrderResponse)
async def update_purchase_order(
order_id: int,
order_data: PurchaseOrderCreate,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
order, _ = await _get_order_with_supplier(db_session, order_id)
if order.paid_amount and order.paid_amount > 0:
raise HTTPException(status_code=400, detail="已付款采购单不允许修改")
item_result = await db_session.execute(
select(PurchaseOrderItem).where(PurchaseOrderItem.order_id == order.id)
)
existing_items = item_result.scalars().all()
if any((item.received_quantity or 0) > 0 for item in existing_items):
raise HTTPException(status_code=400, detail="已发生入库的采购单不允许直接修改")
for item in existing_items:
await db_session.delete(item)
try:
order.supplier_id = order_data.supplier_id
order.expected_date = order_data.expected_date
order.remark = order_data.remark
order.total_amount = await _apply_order_items(db_session, order, order_data)
order.status = "pending"
await db_session.commit()
except (HTTPException, Exception):
await db_session.rollback()
raise
await db_session.refresh(order)
supplier_result = await db_session.execute(select(Supplier).where(Supplier.id == order.supplier_id))
supplier = supplier_result.scalar_one_or_none()
return _build_purchase_order_response(order, supplier.name if supplier else "未知供应商")
@router.delete("/{order_id}")
async def delete_purchase_order(
order_id: int,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
order, _ = await _get_order_with_supplier(db_session, order_id)
if order.paid_amount and order.paid_amount > 0:
raise HTTPException(status_code=400, detail="已付款采购单不允许删除")
item_result = await db_session.execute(
select(PurchaseOrderItem).where(PurchaseOrderItem.order_id == order.id)
)
if any((item.received_quantity or 0) > 0 for item in item_result.scalars().all()):
raise HTTPException(status_code=400, detail="已发生入库的采购单不允许删除")
await db_session.delete(order)
await db_session.commit()
return {"message": "采购订单已删除"}
@router.patch("/{order_id}/status")
async def update_purchase_order_status(
order_id: int,
status: dict,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
order, _ = await _get_order_with_supplier(db_session, order_id)
new_status = status.get("status")
if not new_status:
raise HTTPException(status_code=400, detail="状态不能为空")
valid_statuses = ["pending", "partial_received", "received", "paid"]
if new_status not in valid_statuses:
raise HTTPException(status_code=400, detail=f"无效的状态值,有效值为: {valid_statuses}")
# 状态转换逻辑
if order.status == "paid":
raise HTTPException(status_code=400, detail="已付款的采购订单禁止修改状态")
if order.status == "received" and new_status != "paid":
raise HTTPException(status_code=400, detail="已收货的采购订单只能标记为已付款")
if order.status == "partial_received" and new_status not in ("received", "paid"):
raise HTTPException(status_code=400, detail="部分收货的采购订单只能标记为已收货或已付款")
# 更新状态和对应时间
order.status = new_status
if new_status == "received":
order.received_date = func.now()
elif new_status == "paid":
order.paid_date = func.now()
await db_session.commit()
await db_session.refresh(order)
supplier_result = await db_session.execute(select(Supplier).where(Supplier.id == order.supplier_id))
supplier = supplier_result.scalar_one_or_none()
return _build_purchase_order_response(order, supplier.name if supplier else "未知供应商")
@router.post("/{order_id}/receive", response_model=PurchaseOrderDetailResponse)
async def receive_purchase_order(
order_id: int,
payload: PurchaseOrderReceiveRequest,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
order, supplier = await _get_order_with_supplier(db_session, order_id)
warehouse = await _resolve_receive_warehouse(db_session, payload.warehouse_id)
item_result = await db_session.execute(
select(PurchaseOrderItem).where(PurchaseOrderItem.order_id == order.id)
)
item_map = {item.id: item for item in item_result.scalars().all()}
if not item_map:
raise HTTPException(status_code=400, detail="采购单无明细,无法入库")
if not payload.items:
raise HTTPException(status_code=400, detail="请提供本次入库明细")
for receive_item in payload.items:
item = item_map.get(receive_item.item_id)
if not item:
raise HTTPException(status_code=400, detail=f"采购明细不存在: {receive_item.item_id}")
if receive_item.receive_quantity <= 0:
raise HTTPException(status_code=400, detail="入库数量必须大于0")
remaining_qty = (item.quantity or 0) - (item.received_quantity or 0)
if receive_item.receive_quantity > remaining_qty:
raise HTTPException(status_code=400, detail=f"明细{item.id}入库超量,剩余可入库{remaining_qty}")
for receive_item in payload.items:
item = item_map[receive_item.item_id]
product_result = await db_session.execute(
select(Product).where(Product.id == item.product_id)
)
product = product_result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=400, detail=f"物料不存在: {item.product_id}")
upd_result = await db_session.execute(
update(Inventory)
.where(Inventory.product_id == item.product_id)
.where(Inventory.warehouse_id == warehouse.id)
.values(quantity=Inventory.quantity + receive_item.receive_quantity)
.returning(Inventory.quantity)
)
after_qty = upd_result.scalar_one_or_none()
if after_qty is None:
inventory = Inventory(
product_id=item.product_id,
warehouse_id=warehouse.id,
quantity=receive_item.receive_quantity,
locked_quantity=0
)
db_session.add(inventory)
await db_session.flush()
before_qty = 0
after_qty = receive_item.receive_quantity
else:
after_qty = int(after_qty)
before_qty = after_qty - receive_item.receive_quantity
item.received_quantity = (item.received_quantity or 0) + receive_item.receive_quantity
movement = StockMovement(
product_id=item.product_id,
warehouse_id=warehouse.id,
movement_type="purchase_in",
quantity=receive_item.receive_quantity,
before_quantity=before_qty,
after_quantity=after_qty,
reference_type="purchase_order",
reference_id=order.id,
reference_no=order.order_no,
unit_price=item.unit_price,
total_amount=Decimal(str(item.unit_price * receive_item.receive_quantity)),
remark=payload.remark or f"采购单{order.order_no}到货入库",
operator_id=current_user.id
)
db_session.add(movement)
all_received = all((item.received_quantity or 0) >= (item.quantity or 0) for item in item_map.values())
any_received = any((item.received_quantity or 0) > 0 for item in item_map.values())
if all_received:
order.status = "received"
order.received_date = func.now()
elif any_received:
order.status = "partial_received"
await db_session.commit()
await db_session.refresh(order)
return await _build_purchase_order_detail(db_session, order, supplier.name)
-737
View File
@@ -1,737 +0,0 @@
"""
销售订单路由模块
提供销售订单的管理功能,包括:
- 销售订单列表查询(支持分页、状态筛选)
- 创建销售订单(自动生成订单号、计算总金额)
- 销售订单明细管理
路由前缀: /api/sales-orders
"""
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, delete, update
from typing import Optional, List
from math import ceil
from pydantic import BaseModel, Field
from decimal import Decimal
from database.database import get_db_session
from services.auth_service import get_current_active_user
from models.database import (
User,
Customer,
Product,
ProductMaterial,
Warehouse,
Inventory,
StockMovement,
SalesOrder,
SalesOrderItem
)
from .schemas import (
SalesOrderCreate,
SalesOrderResponse,
SalesOrderDetailResponse,
SalesOrderItemResponse,
SalesOrderProductionPlanResponse,
ProductionMaterialPlanItemResponse,
SalesOrderIssueRequest,
SalesOrderIssueResponse,
SalesOrderStatusUpdate,
PaginatedResponse,
)
from .utils import generate_order_no
router = APIRouter(prefix="/sales-orders", tags=["销售订单"])
VALID_ORDER_STATUSES = {"draft", "manufacturing", "delivered", "paid"}
PRODUCTION_STATUSES = {"not_started", "bom_missing", "material_issued", "completed"}
def _build_sales_order_response(order: SalesOrder, customer_name: str) -> SalesOrderResponse:
return SalesOrderResponse(
id=order.id,
order_no=order.order_no,
customer_name=customer_name,
order_date=order.order_date,
delivery_date=order.delivery_date,
manufacturing_date=order.manufacturing_date,
actual_delivery_date=order.actual_delivery_date,
actual_payment_date=order.actual_payment_date,
status=order.status,
production_status=order.production_status or "not_started",
production_no=order.production_no,
planned_material_cost=Decimal(str(order.planned_material_cost or 0)),
actual_material_cost=Decimal(str(order.actual_material_cost or 0)),
total_amount=Decimal(str(order.total_amount or 0)),
received_amount=Decimal(str(order.received_amount or 0)),
remark=order.remark,
created_at=order.created_at
)
async def _build_sales_order_detail_response(
db_session: AsyncSession,
order: SalesOrder,
customer_name: str
) -> SalesOrderDetailResponse:
items_result = await db_session.execute(
select(SalesOrderItem).where(SalesOrderItem.order_id == order.id).order_by(SalesOrderItem.id.asc())
)
items = items_result.scalars().all()
return SalesOrderDetailResponse(
id=order.id,
order_no=order.order_no,
customer_id=order.customer_id,
customer_name=customer_name,
order_date=order.order_date,
delivery_date=order.delivery_date,
manufacturing_date=order.manufacturing_date,
actual_delivery_date=order.actual_delivery_date,
actual_payment_date=order.actual_payment_date,
status=order.status,
production_status=order.production_status or "not_started",
production_no=order.production_no,
planned_material_cost=Decimal(str(order.planned_material_cost or 0)),
actual_material_cost=Decimal(str(order.actual_material_cost or 0)),
total_amount=Decimal(str(order.total_amount or 0)),
received_amount=Decimal(str(order.received_amount or 0)),
remark=order.remark,
created_at=order.created_at,
items=[
SalesOrderItemResponse(
id=item.id,
product_id=item.product_id,
quantity=item.quantity,
delivered_quantity=item.delivered_quantity,
unit_price=Decimal(str(item.unit_price or 0)),
amount=Decimal(str(item.amount or 0)),
remark=item.remark
) for item in items
]
)
async def _get_sales_order_with_customer(
db_session: AsyncSession,
order_id: int,
) -> tuple[SalesOrder, Customer]:
result = await db_session.execute(
select(SalesOrder, Customer)
.join(Customer, SalesOrder.customer_id == Customer.id)
.where(SalesOrder.id == order_id)
)
row = result.first()
if not row:
raise HTTPException(status_code=404, detail="销售订单不存在")
return row[0], row[1]
async def _build_material_plan(db_session: AsyncSession, order: SalesOrder) -> tuple[List[ProductionMaterialPlanItemResponse], float]:
item_result = await db_session.execute(
select(SalesOrderItem).where(SalesOrderItem.order_id == order.id)
)
order_items = item_result.scalars().all()
if not order_items:
return [], 0
finished_ids = list({int(i.product_id) for i in order_items})
bom_result = await db_session.execute(
select(ProductMaterial, Product)
.join(Product, ProductMaterial.material_product_id == Product.id)
.where(ProductMaterial.finished_product_id.in_(finished_ids))
.where(Product.is_active == True)
.where(Product.item_type == "material")
)
bom_rows = bom_result.all()
if not bom_rows:
return [], 0
bom_by_finished_id = {}
for bom, material in bom_rows:
bom_by_finished_id.setdefault(int(bom.finished_product_id), []).append((bom, material))
required_qty_map = {}
for order_item in order_items:
bom_items = bom_by_finished_id.get(int(order_item.product_id)) or []
for bom, material in bom_items:
qty = Decimal(str(order_item.quantity)) * Decimal(str(bom.quantity or 0)) * (1 + Decimal(str(bom.loss_rate or 0)))
entry = required_qty_map.setdefault(material.id, {"material": material, "required_qty": Decimal("0")})
entry["required_qty"] += qty
if not required_qty_map:
return [], Decimal("0")
material_ids = list(required_qty_map.keys())
stock_result = await db_session.execute(
select(Inventory.product_id, func.coalesce(func.sum(Inventory.quantity), 0))
.where(Inventory.product_id.in_(material_ids))
.group_by(Inventory.product_id)
)
stock_map = {row[0]: Decimal(str(row[1] or 0)) for row in stock_result.all()}
plan_items = []
planned_material_cost = Decimal("0")
for material_id, entry in required_qty_map.items():
material = entry["material"]
required_qty = int(ceil(entry["required_qty"]))
available_qty = stock_map.get(material_id, Decimal("0"))
shortage_qty = max(required_qty - int(available_qty), 0)
unit_cost = Decimal(str(material.cost_price or 0))
required_cost = Decimal(str(required_qty)) * unit_cost
planned_material_cost += required_cost
plan_items.append(
ProductionMaterialPlanItemResponse(
material_id=material.id,
material_sku=material.sku,
material_name=material.name,
required_quantity=Decimal(str(required_qty)),
available_quantity=available_qty,
shortage_quantity=Decimal(str(shortage_qty)),
unit_cost=unit_cost,
required_cost=required_cost,
)
)
plan_items = sorted(plan_items, key=lambda x: (x.shortage_quantity, x.required_cost), reverse=True)
return plan_items, planned_material_cost
async def _get_default_warehouse(db_session: AsyncSession) -> Warehouse:
warehouse_result = await db_session.execute(
select(Warehouse).where(Warehouse.is_active == True).order_by(Warehouse.is_default.desc(), Warehouse.id.asc())
)
warehouse = warehouse_result.scalars().first()
if not warehouse:
raise HTTPException(status_code=400, detail="未配置可用仓库,无法自动扣减物料")
return warehouse
async def _issue_materials_for_order_creation(
db_session: AsyncSession,
order: SalesOrder,
current_user: User
) -> tuple[int, float, float]:
warehouse = await _get_default_warehouse(db_session)
plan_items, planned_material_cost = await _build_material_plan(db_session, order)
if not plan_items:
order.production_no = order.production_no or generate_order_no("WO")
order.production_status = "bom_missing"
order.planned_material_cost = 0
order.actual_material_cost = 0
order.status = "manufacturing"
return 0, 0, 0
shortage_items = [item for item in plan_items if item.shortage_quantity > 0]
if shortage_items:
shortage_text = ",".join([f"{item.material_name} 缺 {item.shortage_quantity}" for item in shortage_items])
raise HTTPException(status_code=400, detail=f"物料库存不足:{shortage_text}")
production_no = order.production_no or generate_order_no("WO")
actual_material_cost = Decimal("0")
movement_count = 0
for item in plan_items:
upd_result = await db_session.execute(
update(Inventory)
.where(Inventory.product_id == item.material_id)
.where(Inventory.warehouse_id == warehouse.id)
.where(Inventory.quantity >= item.required_quantity)
.values(quantity=Inventory.quantity - item.required_quantity)
.returning(Inventory.quantity)
)
after_qty = upd_result.scalar_one_or_none()
if after_qty is None:
raise HTTPException(status_code=400, detail=f"{item.material_name} 在默认仓库库存不足")
after_qty = int(after_qty)
before_qty = after_qty + int(item.required_quantity)
total_amount = item.required_quantity * item.unit_cost
actual_material_cost += total_amount
movement = StockMovement(
product_id=item.material_id,
warehouse_id=warehouse.id,
movement_type="issue_to_production",
quantity=item.required_quantity,
before_quantity=before_qty,
after_quantity=after_qty,
reference_type="sales_order",
reference_id=order.id,
reference_no=production_no,
unit_price=item.unit_cost,
total_amount=Decimal(str(total_amount)),
remark=f"销售单{order.order_no}创建时自动扣减物料",
operator_id=current_user.id
)
db_session.add(movement)
movement_count += 1
order.production_no = production_no
order.production_status = "material_issued"
order.planned_material_cost = planned_material_cost
order.actual_material_cost = actual_material_cost
order.status = "manufacturing"
return movement_count, planned_material_cost, actual_material_cost
async def _rollback_issued_materials(
db_session: AsyncSession,
order: SalesOrder,
current_user: User
):
movement_result = await db_session.execute(
select(StockMovement)
.where(StockMovement.reference_type == "sales_order")
.where(StockMovement.reference_id == order.id)
.where(StockMovement.movement_type == "issue_to_production")
.order_by(StockMovement.id.asc())
)
movements = movement_result.scalars().all()
if not movements:
return
for movement in movements:
upd_result = await db_session.execute(
update(Inventory)
.where(Inventory.product_id == movement.product_id)
.where(Inventory.warehouse_id == movement.warehouse_id)
.values(quantity=Inventory.quantity + movement.quantity)
.returning(Inventory.quantity)
)
after_qty = upd_result.scalar_one_or_none()
if after_qty is None:
inventory = Inventory(
product_id=movement.product_id,
warehouse_id=movement.warehouse_id,
quantity=movement.quantity,
locked_quantity=0
)
db_session.add(inventory)
await db_session.flush()
before_qty = 0
after_qty = movement.quantity
else:
after_qty = int(after_qty)
before_qty = after_qty - movement.quantity
revert_movement = StockMovement(
product_id=movement.product_id,
warehouse_id=movement.warehouse_id,
movement_type="return_from_production",
quantity=movement.quantity,
before_quantity=before_qty,
after_quantity=after_qty,
reference_type="sales_order",
reference_id=order.id,
reference_no=order.production_no or order.order_no,
unit_price=movement.unit_price,
total_amount=movement.total_amount,
remark=f"销售单{order.order_no}变更/删除,自动回补物料",
operator_id=current_user.id
)
db_session.add(revert_movement)
async def _apply_order_items(
db_session: AsyncSession,
order: SalesOrder,
order_data: SalesOrderCreate
) -> Decimal:
total_amount = Decimal("0")
for item_data in order_data.items:
product = None
if item_data.product_id is not None:
product_result = await db_session.execute(
select(Product).where(Product.id == item_data.product_id, Product.is_active == True)
)
product = product_result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=400, detail=f"产品不存在: {item_data.product_id}")
else:
if not item_data.product_sku or not item_data.product_name:
raise HTTPException(status_code=400, detail="请提供产品ID,或提供产品SKU与产品名称")
by_sku_result = await db_session.execute(
select(Product).where(Product.sku == item_data.product_sku, Product.is_active == True)
)
product = by_sku_result.scalar_one_or_none()
if not product:
product = Product(
sku=item_data.product_sku,
name=item_data.product_name,
category=item_data.product_category,
unit=item_data.product_unit or "件",
item_type="finished",
cost_price=0,
sale_price=item_data.unit_price or 0,
min_stock=0,
max_stock=0
)
db_session.add(product)
await db_session.flush()
if product.item_type != "finished":
raise HTTPException(status_code=400, detail=f"销售单仅允许成品: {product.name}")
item = SalesOrderItem(
order_id=order.id,
product_id=product.id,
quantity=item_data.quantity,
unit_price=item_data.unit_price,
amount=item_data.quantity * item_data.unit_price,
remark=item_data.remark
)
db_session.add(item)
total_amount += item.amount
return total_amount
@router.get("", response_model=PaginatedResponse[SalesOrderResponse])
async def list_sales_orders(
status: Optional[str] = None,
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
base_query = (
select(SalesOrder, Customer)
.join(Customer, SalesOrder.customer_id == Customer.id)
.order_by(SalesOrder.created_at.desc())
)
if status:
base_query = base_query.where(SalesOrder.status == status)
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)
orders = []
for order, customer in result.all():
orders.append(_build_sales_order_response(order, customer.name))
return PaginatedResponse(items=orders, total=total, skip=skip, limit=limit)
@router.post("", response_model=SalesOrderResponse, status_code=201)
async def create_sales_order(
order_data: SalesOrderCreate,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
from datetime import datetime
now = datetime.now()
order = SalesOrder(
order_no=generate_order_no("SO"),
customer_id=order_data.customer_id,
order_date=now,
delivery_date=order_data.delivery_date,
manufacturing_date=now,
created_at=now,
remark=order_data.remark,
operator_id=current_user.id,
status="manufacturing"
)
db_session.add(order)
await db_session.flush()
try:
order.total_amount = await _apply_order_items(db_session, order, order_data)
await _issue_materials_for_order_creation(db_session, order, current_user)
await db_session.commit()
except (HTTPException, Exception):
await db_session.rollback()
raise
await db_session.refresh(order)
customer = await db_session.execute(select(Customer).where(Customer.id == order.customer_id))
customer = customer.scalar_one()
return _build_sales_order_response(order, customer.name)
@router.get("/{order_id}", response_model=SalesOrderDetailResponse)
async def get_sales_order_detail(
order_id: int,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
order, customer = await _get_sales_order_with_customer(db_session, order_id)
return await _build_sales_order_detail_response(db_session, order, customer.name)
@router.put("/{order_id}", response_model=SalesOrderResponse)
async def update_sales_order(
order_id: int,
order_data: SalesOrderCreate,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
order, customer = await _get_sales_order_with_customer(db_session, order_id)
if order.status == "paid":
raise HTTPException(status_code=400, detail="已收款的销售订单禁止修改")
try:
await _rollback_issued_materials(db_session, order, current_user)
await db_session.execute(delete(SalesOrderItem).where(SalesOrderItem.order_id == order.id))
order.customer_id = order_data.customer_id
order.delivery_date = order_data.delivery_date
order.remark = order_data.remark
order.production_status = "not_started"
order.production_no = None
order.planned_material_cost = 0
order.actual_material_cost = 0
order.status = "manufacturing"
order.total_amount = await _apply_order_items(db_session, order, order_data)
await _issue_materials_for_order_creation(db_session, order, current_user)
await db_session.commit()
except (HTTPException, Exception):
await db_session.rollback()
raise
await db_session.refresh(order)
customer_result = await db_session.execute(select(Customer).where(Customer.id == order.customer_id))
updated_customer = customer_result.scalar_one_or_none()
customer_name = updated_customer.name if updated_customer else "未知客户"
return _build_sales_order_response(order, customer_name)
@router.patch("/{order_id}/status", response_model=SalesOrderResponse)
async def update_sales_order_status(
order_id: int,
payload: SalesOrderStatusUpdate,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
if payload.status not in VALID_ORDER_STATUSES:
raise HTTPException(status_code=400, detail="订单状态必须为 manufacturing、delivered、paid")
order, customer = await _get_sales_order_with_customer(db_session, order_id)
# 只禁止从已交付状态改为非已收款状态
if order.status == "delivered" and payload.status != "paid":
raise HTTPException(status_code=400, detail="已交付的销售订单只能修改为已收款状态")
# 根据状态更新相应的日期字段
from datetime import datetime
if payload.status == "delivered" and not order.actual_delivery_date:
order.actual_delivery_date = datetime.now()
elif payload.status == "paid" and not order.actual_payment_date:
order.actual_payment_date = datetime.now()
order.status = payload.status
await db_session.commit()
await db_session.refresh(order)
return _build_sales_order_response(order, customer.name)
@router.delete("/{order_id}")
async def delete_sales_order(
order_id: int,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
order, customer = await _get_sales_order_with_customer(db_session, order_id)
if order.status == "delivered":
raise HTTPException(status_code=400, detail="已交付的销售订单禁止删除")
await _rollback_issued_materials(db_session, order, current_user)
await db_session.delete(order)
await db_session.commit()
return {"message": "销售订单已删除"}
class MaterialConsumptionItem(BaseModel):
material_id: int
quantity: float = Field(gt=0)
remark: Optional[str] = None
class MaterialConsumptionRequest(BaseModel):
items: List[MaterialConsumptionItem] = Field(min_length=1)
@router.post("/{order_id}/consume-materials")
async def consume_materials(
order_id: int,
request: MaterialConsumptionRequest,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
"""记录销售订单的物料消耗"""
order, customer = await _get_sales_order_with_customer(db_session, order_id)
default_warehouse = await _get_default_warehouse(db_session)
# 计算总物料成本
total_cost = Decimal("0")
# 处理每个物料消耗项
for item in request.items:
# 获取物料信息
material = await db_session.get(Product, item.material_id)
if not material:
raise HTTPException(status_code=404, detail=f"物料 ID {item.material_id} 不存在")
if material.item_type != "material":
raise HTTPException(status_code=400, detail=f"只能消耗物料类型的产品: {material.name}")
# 计算成本
cost = Decimal(str(material.cost_price or 0)) * item.quantity
total_cost += cost
# 更新物料库存(原子操作防并发)
upd_result = await db_session.execute(
update(Inventory)
.where(Inventory.product_id == material.id)
.where(Inventory.warehouse_id == default_warehouse.id)
.where(Inventory.quantity >= item.quantity)
.values(quantity=Inventory.quantity - item.quantity)
.returning(Inventory.quantity)
)
after_qty = upd_result.scalar_one_or_none()
if after_qty is None:
raise HTTPException(status_code=400, detail=f"物料 {material.name} 库存不足")
after_qty = int(after_qty)
before_qty = after_qty + int(item.quantity)
# 记录物料消耗
movement = StockMovement(
product_id=material.id,
warehouse_id=default_warehouse.id,
quantity=item.quantity,
before_quantity=before_qty,
after_quantity=after_qty,
movement_type="consumption",
reference_type="sales_order",
reference_id=order.id,
unit_price=material.cost_price,
total_amount=cost,
operator_id=current_user.id,
remark=item.remark
)
db_session.add(movement)
# 更新订单的实际物料成本
order.actual_material_cost = total_cost
await db_session.commit()
await db_session.refresh(order)
return {
"message": "物料消耗记录保存成功",
"total_cost": total_cost,
"order": _build_sales_order_response(order, customer.name)
}
@router.get("/{order_id}/production-plan", response_model=SalesOrderProductionPlanResponse)
async def get_sales_order_production_plan(
order_id: int,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
order, customer = await _get_sales_order_with_customer(db_session, order_id)
production_no = order.production_no or generate_order_no("WO")
plan_items, planned_material_cost = await _build_material_plan(db_session, order)
return SalesOrderProductionPlanResponse(
sales_order_id=order.id,
order_no=order.order_no,
customer_name=customer.name,
production_no=production_no,
planned_material_cost=planned_material_cost,
items=plan_items,
)
@router.post("/{order_id}/issue-materials", response_model=SalesOrderIssueResponse)
async def issue_sales_order_materials(
order_id: int,
payload: SalesOrderIssueRequest,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
order, _ = await _get_sales_order_with_customer(db_session, order_id)
if order.status == "delivered":
raise HTTPException(status_code=400, detail="已交付的销售订单禁止领料")
if order.production_status == "completed":
raise HTTPException(status_code=400, detail="该销售单已完成生产")
if order.production_status == "material_issued":
raise HTTPException(status_code=400, detail="该销售单已自动扣减过物料")
warehouse_result = await db_session.execute(
select(Warehouse).where(Warehouse.id == payload.warehouse_id, Warehouse.is_active == True)
)
warehouse = warehouse_result.scalar_one_or_none()
if not warehouse:
raise HTTPException(status_code=404, detail="仓库不存在")
plan_items, planned_material_cost = await _build_material_plan(db_session, order)
if not plan_items:
raise HTTPException(status_code=400, detail="该销售单未配置BOM,无法领料")
shortage_items = [item for item in plan_items if item.shortage_quantity > 0]
if shortage_items:
shortage_text = ",".join([f"{item.material_name} 缺 {item.shortage_quantity}" for item in shortage_items])
raise HTTPException(status_code=400, detail=f"物料库存不足:{shortage_text}")
production_no = payload.production_no or order.production_no or generate_order_no("WO")
actual_material_cost = Decimal("0")
movement_count = 0
for item in plan_items:
upd_result = await db_session.execute(
update(Inventory)
.where(Inventory.product_id == item.material_id)
.where(Inventory.warehouse_id == warehouse.id)
.where(Inventory.quantity >= item.required_quantity)
.values(quantity=Inventory.quantity - item.required_quantity)
.returning(Inventory.quantity)
)
after_qty = upd_result.scalar_one_or_none()
if after_qty is None:
raise HTTPException(status_code=400, detail=f"{item.material_name} 在所选仓库库存不足")
after_qty = int(after_qty)
before_qty = after_qty + int(item.required_quantity)
total_amount = item.required_quantity * item.unit_cost
actual_material_cost += total_amount
movement = StockMovement(
product_id=item.material_id,
warehouse_id=warehouse.id,
movement_type="issue_to_production",
quantity=item.required_quantity,
before_quantity=before_qty,
after_quantity=after_qty,
reference_type="sales_order",
reference_id=order.id,
reference_no=production_no,
unit_price=item.unit_cost,
total_amount=Decimal(str(total_amount)),
remark=payload.remark or f"销售单{order.order_no}按单生产领料",
operator_id=current_user.id
)
db_session.add(movement)
movement_count += 1
order.production_no = production_no
order.production_status = "material_issued"
order.planned_material_cost = planned_material_cost
order.actual_material_cost = actual_material_cost
if order.status == "draft":
order.status = "manufacturing"
await db_session.commit()
cost_deviation = actual_material_cost - planned_material_cost
cost_deviation_rate = (cost_deviation / planned_material_cost) if planned_material_cost > Decimal("1e-9") else Decimal("0")
return SalesOrderIssueResponse(
sales_order_id=order.id,
order_no=order.order_no,
production_no=production_no,
movement_count=movement_count,
planned_material_cost=planned_material_cost,
actual_material_cost=actual_material_cost,
cost_deviation=cost_deviation,
cost_deviation_rate=cost_deviation_rate,
production_status=order.production_status,
)
-82
View File
@@ -1,82 +0,0 @@
from .product_schemas import (
ProductCreate,
ProductResponse,
ProductMaterialItemUpdate,
ProductBOMUpdate,
ProductMaterialItemResponse,
ProductBOMResponse
)
from .supplier_schemas import SupplierCreate, SupplierResponse
from .customer_schemas import CustomerCreate, CustomerResponse
from .warehouse_schemas import WarehouseCreate, WarehouseResponse
from .inventory_schemas import InventoryResponse, InventoryCreate, InventoryUpdate
from .stock_movement_schemas import StockMovementCreate, StockMovementResponse
from .purchase_order_schemas import (
PurchaseOrderCreate,
PurchaseOrderResponse,
PurchaseOrderItemCreate,
PurchaseOrderItemResponse,
PurchaseOrderDetailResponse,
PurchaseOrderReceiveItem,
PurchaseOrderReceiveRequest
)
from .sales_order_schemas import (
SalesOrderCreate,
SalesOrderResponse,
SalesOrderItemCreate,
SalesOrderItemResponse,
SalesOrderDetailResponse,
ProductionMaterialPlanItemResponse,
SalesOrderProductionPlanResponse,
SalesOrderIssueRequest,
SalesOrderIssueResponse,
SalesOrderStatusUpdate
)
from .finance_schemas import (
FinanceAllocationCreate,
FinanceTransactionCreate,
ReceiptCreate,
PaymentCreate,
FinanceAllocationResponse,
FinanceTransactionResponse,
FinanceSummaryResponse,
ReceivableItemResponse,
PayableItemResponse,
PartnerStatementItemResponse,
FinancePartnerStatementResponse,
PartnerProductStatementItemResponse,
FinancePartnerProductStatementResponse
)
from .material_schemas import (
MaterialPriceHistoryCreate,
MaterialPriceHistoryResponse,
MaterialSupplierCreate,
MaterialSupplierResponse,
MaterialPriceTrendResponse
)
from .common_schemas import PaginatedResponse
__all__ = [
"PaginatedResponse",
"ProductCreate", "ProductResponse", "ProductMaterialItemUpdate", "ProductBOMUpdate",
"ProductMaterialItemResponse", "ProductBOMResponse",
"SupplierCreate", "SupplierResponse",
"CustomerCreate", "CustomerResponse",
"WarehouseCreate", "WarehouseResponse",
"InventoryResponse", "InventoryCreate", "InventoryUpdate",
"StockMovementCreate", "StockMovementResponse",
"PurchaseOrderCreate", "PurchaseOrderResponse", "PurchaseOrderItemCreate",
"PurchaseOrderItemResponse", "PurchaseOrderDetailResponse", "PurchaseOrderReceiveItem", "PurchaseOrderReceiveRequest",
"SalesOrderCreate", "SalesOrderResponse", "SalesOrderItemCreate", "SalesOrderItemResponse", "SalesOrderDetailResponse",
"ProductionMaterialPlanItemResponse", "SalesOrderProductionPlanResponse",
"SalesOrderIssueRequest", "SalesOrderIssueResponse", "SalesOrderStatusUpdate",
"FinanceAllocationCreate", "FinanceTransactionCreate",
"ReceiptCreate", "PaymentCreate",
"FinanceAllocationResponse", "FinanceTransactionResponse",
"FinanceSummaryResponse", "ReceivableItemResponse", "PayableItemResponse",
"PartnerStatementItemResponse", "FinancePartnerStatementResponse",
"PartnerProductStatementItemResponse", "FinancePartnerProductStatementResponse",
"MaterialPriceHistoryCreate", "MaterialPriceHistoryResponse",
"MaterialSupplierCreate", "MaterialSupplierResponse", "MaterialPriceTrendResponse",
]
@@ -1,11 +0,0 @@
from typing import TypeVar, Generic, List
from pydantic import BaseModel
T = TypeVar("T")
class PaginatedResponse(BaseModel, Generic[T]):
items: List[T]
total: int
skip: int
limit: int
@@ -1,63 +0,0 @@
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
from decimal import Decimal
class ProductCreate(BaseModel):
sku: str
name: str
description: Optional[str] = None
category: Optional[str] = None
unit: str = "件"
item_type: str = "finished"
cost_price: Decimal = Decimal("0")
sale_price: Decimal = Decimal("0")
min_stock: int = 0
max_stock: int = 1000
class ProductResponse(BaseModel):
id: int
sku: str
name: str
description: Optional[str]
category: Optional[str]
unit: str
item_type: str
cost_price: Decimal
sale_price: Decimal
min_stock: int
max_stock: int
material_cost: Decimal = Decimal("0")
is_active: bool
created_at: datetime
class Config:
from_attributes = True
class ProductMaterialItemUpdate(BaseModel):
material_id: int
quantity: Decimal
loss_rate: Decimal = Decimal("0")
class ProductBOMUpdate(BaseModel):
items: List[ProductMaterialItemUpdate]
class ProductMaterialItemResponse(BaseModel):
material_id: int
material_sku: str
material_name: str
quantity: Decimal
unit_cost: Decimal
line_cost: Decimal
class ProductBOMResponse(BaseModel):
product_id: int
product_name: str
total_material_cost: Decimal
items: List[ProductMaterialItemResponse]
-209
View File
@@ -1,209 +0,0 @@
"""
库存变动路由模块
提供库存变动的管理功能,包括:
- 创建库存变动记录(入库、出库、调整)
- 库存变动历史查询(支持分页、产品筛选、变动类型筛选)
- 自动更新库存数量
- 库存不足校验(出库时)
路由前缀: /api/stock-movements
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, func
from typing import Optional, List
from database.database import get_db_session
from services.auth_service import get_current_active_user
from models.database import User, Product, Warehouse, Inventory, StockMovement
from .schemas import StockMovementCreate, StockMovementResponse, PaginatedResponse
from .utils import generate_order_no
router = APIRouter(prefix="/stock-movements", tags=["库存变动"])
INBOUND_TYPES = {
"in", "purchase_in", "return_from_production", "outsource_return", "finish_in"
}
OUTBOUND_TYPES = {
"out", "issue_to_production", "outsource_send", "shipment_out", "scrap_out"
}
ADJUST_TYPES = {"adjust"}
SUPPORTED_MOVEMENT_TYPES = INBOUND_TYPES | OUTBOUND_TYPES | ADJUST_TYPES
@router.post("", response_model=StockMovementResponse, status_code=201)
async def create_stock_movement(
movement_data: StockMovementCreate,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
if movement_data.movement_type not in SUPPORTED_MOVEMENT_TYPES:
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="产品不存在,请选择系统中的产品")
if product.item_type != "material":
raise HTTPException(status_code=400, detail="库存仅管理物料,该条目不是物料")
resolved_product_id = product.id
if movement_data.movement_type in INBOUND_TYPES:
upd_result = await db_session.execute(
update(Inventory)
.where(Inventory.product_id == resolved_product_id)
.where(Inventory.warehouse_id == movement_data.warehouse_id)
.values(quantity=Inventory.quantity + movement_data.quantity)
.returning(Inventory.quantity)
)
after_qty = upd_result.scalar_one_or_none()
if after_qty is None:
inventory = Inventory(
product_id=resolved_product_id,
warehouse_id=movement_data.warehouse_id,
quantity=movement_data.quantity,
)
db_session.add(inventory)
await db_session.flush()
before_qty = 0
after_qty = movement_data.quantity
else:
after_qty = int(after_qty)
before_qty = after_qty - movement_data.quantity
elif movement_data.movement_type in OUTBOUND_TYPES:
upd_result = await db_session.execute(
update(Inventory)
.where(Inventory.product_id == resolved_product_id)
.where(Inventory.warehouse_id == movement_data.warehouse_id)
.where(Inventory.quantity >= movement_data.quantity)
.values(quantity=Inventory.quantity - movement_data.quantity)
.returning(Inventory.quantity)
)
after_qty = upd_result.scalar_one_or_none()
if after_qty is None:
raise HTTPException(status_code=400, detail="库存不足")
after_qty = int(after_qty)
before_qty = after_qty + movement_data.quantity
else:
result = await db_session.execute(
select(Inventory)
.where(Inventory.product_id == resolved_product_id)
.where(Inventory.warehouse_id == movement_data.warehouse_id)
.with_for_update()
)
inventory = result.scalar_one_or_none()
if not inventory:
inventory = Inventory(
product_id=resolved_product_id,
warehouse_id=movement_data.warehouse_id,
quantity=0,
)
db_session.add(inventory)
await db_session.flush()
before_qty = 0
else:
before_qty = int(inventory.quantity)
inventory.quantity = movement_data.quantity
after_qty = movement_data.quantity
movement = StockMovement(
product_id=resolved_product_id,
warehouse_id=movement_data.warehouse_id,
movement_type=movement_data.movement_type,
quantity=movement_data.quantity,
before_quantity=before_qty,
after_quantity=after_qty,
reference_no=generate_order_no("SM"),
unit_price=movement_data.unit_price,
total_amount=movement_data.unit_price * movement_data.quantity if movement_data.unit_price else None,
remark=movement_data.remark,
operator_id=current_user.id
)
db_session.add(movement)
await db_session.commit()
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,
before_quantity=movement.before_quantity,
after_quantity=movement.after_quantity,
reference_no=movement.reference_no,
remark=movement.remark,
created_at=movement.created_at
)
@router.get("", response_model=PaginatedResponse[StockMovementResponse])
async def list_stock_movements(
product_id: Optional[int] = None,
movement_type: Optional[str] = None,
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
base_query = (
select(StockMovement, Product)
.join(Product, StockMovement.product_id == Product.id)
.order_by(StockMovement.created_at.desc())
)
if product_id:
base_query = base_query.where(StockMovement.product_id == product_id)
if movement_type:
base_query = base_query.where(StockMovement.movement_type == movement_type)
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)
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,
before_quantity=movement.before_quantity,
after_quantity=movement.after_quantity,
reference_no=movement.reference_no,
remark=movement.remark,
created_at=movement.created_at
))
return PaginatedResponse(items=movements, total=total, skip=skip, limit=limit)
-22
View File
@@ -1,22 +0,0 @@
"""
库存管理工具函数模块
提供进销存系统通用的工具函数,包括:
- 订单编号生成器(采购订单、销售订单、库存变动等)
"""
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}"
+18
View File
@@ -14,6 +14,7 @@ from utils.html_generator import HTMLGenerator
from services.storage_integration_rustfs import StorageIntegrationService
from services.redis_task_manager import redis_task_manager
from services.processing_service import processing_service
from services.llm_service import llm_service
from services.task_query_service import TaskQueryService
from database.database import get_db_session
from utils.logger import get_logger
@@ -1115,6 +1116,21 @@ async def process_file_core(
logger.info("FreeCAD验证已禁用(设置 ENABLE_FREECAD_VERIFICATION=true 启用)")
verification_result = {"status": "disabled", "reason": "FreeCAD验证已禁用"}
# 9.8 LLM 增强分析(可选,不影响主流程)
llm_report = None
llm_parting = None
if analysis_result:
llm_report = await llm_service.generate_design_report(
analysis_result, detailed_cavity_json
)
# 分型推荐:当前无候选方案时跳过
candidate_schemes = detailed_cavity_json.get("candidate_schemes", [])
if candidate_schemes:
llm_parting = await llm_service.recommend_parting_direction(
geometry_data, candidate_schemes, selected_material,
cavity_count=cavity_count,
)
# 10. 完成处理
await storage_service.update_stp_file_status(db_session, stp_file_id, "completed")
await storage_service.update_task_status(
@@ -1129,6 +1145,8 @@ async def process_file_core(
tasks[task_id]["key_info"] = detailed_cavity_json # 传递完整数据给前端
tasks[task_id]["html_file"] = f"/html/{Path(html_file_path).name}" # 只使用文件名
tasks[task_id]["verification"] = verification_result # 添加验证结果
tasks[task_id]["llm_report"] = llm_report
tasks[task_id]["llm_parting_recommendation"] = llm_parting
tasks[task_id]["status"] = ProcessingStatus.COMPLETED
tasks[task_id]["completed_at"] = str(datetime.now())
-905
View File
@@ -1,905 +0,0 @@
from typing import Dict, List, Any, Tuple, Optional
import math
import numpy as np
from OCC.Core.BRepOffsetAPI import BRepOffsetAPI_DraftAngle
from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut, BRepAlgoAPI_Section, BRepAlgoAPI_Common
from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_MakeFace, BRepBuilderAPI_Transform
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeHalfSpace
from OCC.Core.gp import gp_Pln, gp_Dir, gp_Pnt, gp_Trsf, gp_Ax2
from OCC.Core.TopoDS import TopoDS_Face, topods
from OCC.Core.BRep import BRep_Tool
from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh
from OCC.Core.GProp import GProp_GProps
from OCC.Core.BRepGProp import brepgprop
from OCC.Core.TopExp import TopExp_Explorer
from OCC.Core.TopAbs import TopAbs_FACE, TopAbs_EDGE
from OCC.Core.BRepAdaptor import BRepAdaptor_Surface, BRepAdaptor_Curve
from OCC.Core.Bnd import Bnd_Box
from OCC.Core.BRepBndLib import brepbndlib
from OCC.Core.TopLoc import TopLoc_Location
from models.schemas import create_mold_cavity_data, create_mold_key_info
from utils.logger import get_logger
logger = get_logger(__name__)
class BaseMoldGenerator:
"""模具生成器基类 - 提供共用方法"""
def __init__(self, shrinkage_rate: float = 0.005, draft_angle: float = 2.0,
material_density: float = 1.05):
self.shrinkage_rate = shrinkage_rate
self.draft_angle = draft_angle
self.material_density = material_density
self.ai_parting_detector: Optional[Any] = None
self.ai_draft_analyzer: Optional[Any] = None
def set_ai_model(self, parting_detector: Any = None, draft_analyzer: Any = None):
self.ai_parting_detector = parting_detector
self.ai_draft_analyzer = draft_analyzer
logger.info("AI 模型接口已设置")
def _apply_shrinkage_compensation(self, shape: Any) -> Any:
scale_factor = 1.0 + self.shrinkage_rate
trsf = gp_Trsf()
trsf.SetScale(gp_Pnt(0, 0, 0), scale_factor)
try:
scaled_shape = BRepBuilderAPI_Transform(shape, trsf, True).Shape()
logger.info(f"收缩率补偿: {self.shrinkage_rate*100:.2f}%, 缩放因子: {scale_factor:.4f}")
return scaled_shape
except Exception as e:
logger.warning(f"收缩率补偿失败: {e}")
return shape
def _apply_draft_angles(self, shape: Any, parting_surface: Any) -> Any:
try:
draft_direction = self._get_draft_direction(parting_surface)
if draft_direction is None:
logger.warning("无法确定拔模方向,跳过拔模处理")
return shape
draft_angle_rad = math.radians(self.draft_angle)
draftable_faces = self._find_draftable_faces(shape, draft_direction)
if not draftable_faces:
logger.info("未找到需要拔模的面,跳过拔模处理")
return shape
logger.info(f"应用拔模角: {self.draft_angle}°, {len(draftable_faces)} 个面")
drafted_shape = self._execute_draft(shape, draftable_faces, draft_direction, draft_angle_rad)
return drafted_shape
except Exception as e:
logger.warning(f"拔模角处理失败,返回原始形状: {e}")
return shape
def _get_draft_direction(self, parting_surface: Any) -> Optional[gp_Dir]:
try:
surface = BRepAdaptor_Surface(parting_surface)
if surface.GetType() == 0:
return surface.Plane().Position().Direction()
return gp_Dir(0, 0, 1)
except Exception:
return gp_Dir(0, 0, 1)
def _find_draftable_faces(self, shape: Any, draft_direction: gp_Dir) -> List[Any]:
draftable = []
explorer = TopExp_Explorer(shape, TopAbs_FACE)
while explorer.More():
face = topods.Face(explorer.Current())
normal = self._get_face_normal(face)
if normal is not None:
dot = abs(normal.Dot(draft_direction))
angle = math.degrees(math.acos(min(dot, 1.0)))
if 5.0 < angle < 85.0:
draftable.append(face)
explorer.Next()
return draftable
def _get_face_normal(self, face: Any) -> Optional[gp_Dir]:
try:
surface = BRepAdaptor_Surface(face)
u = (surface.FirstUParameter() + surface.LastUParameter()) / 2
v = (surface.FirstVParameter() + surface.LastVParameter()) / 2
if surface.GetType() == 0:
return surface.Plane().Position().Direction()
from OCC.Core.BRepLProp import BRepLProp_SLProps
props = BRepLProp_SLProps(surface, 1, 0.001)
props.SetParameters(u, v)
if props.IsNormalDefined():
return props.Normal()
return None
except Exception:
return None
def _execute_draft(self, shape: Any, faces: List[Any],
draft_direction: gp_Dir, draft_angle_rad: float) -> Any:
try:
draft = BRepOffsetAPI_DraftAngle(shape)
for face in faces:
try:
normal = self._get_face_normal(face)
if normal is None:
continue
dot = normal.Dot(draft_direction)
if dot > 0:
face_dir = draft_direction
else:
face_dir = gp_Dir(-draft_direction.X(), -draft_direction.Y(), -draft_direction.Z())
draft.Add(face, face_dir, draft_angle_rad, True)
except Exception:
continue
draft.Build()
if draft.IsDone():
logger.info(f"拔模角应用成功: {len(faces)} 个面, {self.draft_angle}°")
return draft.Shape()
else:
logger.warning("BRepOffsetAPI_DraftAngle 构建失败,尝试逐面拔模")
return self._draft_faces_sequentially(shape, faces, draft_direction, draft_angle_rad)
except Exception as e:
logger.warning(f"拔模执行失败: {e}")
return shape
def _draft_faces_sequentially(self, shape: Any, faces: List[Any],
draft_direction: gp_Dir, draft_angle_rad: float) -> Any:
current_shape = shape
success_count = 0
for face in faces:
try:
draft = BRepOffsetAPI_DraftAngle(current_shape)
normal = self._get_face_normal(face)
if normal is None:
continue
dot = normal.Dot(draft_direction)
if dot > 0:
face_dir = draft_direction
else:
face_dir = gp_Dir(-draft_direction.X(), -draft_direction.Y(), -draft_direction.Z())
draft.Add(face, face_dir, draft_angle_rad, True)
draft.Build()
if draft.IsDone():
current_shape = draft.Shape()
success_count += 1
except Exception:
continue
if success_count > 0:
logger.info(f"逐面拔模完成: {success_count}/{len(faces)} 个面成功")
else:
logger.warning("逐面拔模全部失败,返回原始形状")
return current_shape
def _analyze_product_geometry(self, shape: Any) -> Dict[str, Any]:
try:
props = GProp_GProps()
brepgprop.VolumeProperties(shape, props)
volume = props.Mass()
surface_props = GProp_GProps()
brepgprop.SurfaceProperties(shape, surface_props)
surface_area = surface_props.Mass()
center = props.CentreOfMass()
bbox = Bnd_Box()
brepbndlib.Add(shape, bbox)
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
inertia = props.MatrixOfInertia()
return {
"volume": volume,
"surface_area": surface_area,
"center_of_mass": [float(center.X()), float(center.Y()), float(center.Z())],
"bounding_box": {
"min": [float(xmin), float(ymin), float(zmin)],
"max": [float(xmax), float(ymax), float(zmax)],
"center": [float((xmin+xmax)/2), float((ymin+ymax)/2), float((zmin+zmax)/2)],
"dimensions": [float(xmax-xmin), float(ymax-ymin), float(zmax-zmin)]
},
"inertia_matrix": self._get_inertia_matrix(props)
}
except Exception as e:
logger.error(f"产品几何分析失败: {e}")
raise
def _split_cavity_core(self, shape: Any, parting_surface: Any, margin: int = 20) -> Tuple[Any, Any]:
"""
用分型面将模具块切分为A板(上模/型腔)和B板(下模/型芯),
然后从每个板中减去产品形状的对应部分。
流程:
1. 创建完整模具块(产品包围盒 + 全方向余量)
2. 用分型面将模具块切分为 A板 和 B板
3. A板 - 产品 = 型腔(凹模)
4. B板 - 产品 = 型芯(凸模)
"""
try:
bbox = Bnd_Box()
brepbndlib.Add(shape, bbox)
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
mold_xmin = xmin - margin
mold_ymin = ymin - margin
mold_zmin = zmin - margin
mold_xmax = xmax + margin
mold_ymax = ymax + margin
mold_zmax = zmax + margin
mold_block = BRepPrimAPI_MakeBox(
gp_Pnt(mold_xmin, mold_ymin, mold_zmin),
gp_Pnt(mold_xmax, mold_ymax, mold_zmax)
).Shape()
parting_plane = self._get_parting_plane(parting_surface, shape)
if parting_plane is None:
logger.warning("无法提取分型面平面,使用回退方案")
center_z = (zmin + zmax) / 2
parting_plane = gp_Pln(gp_Pnt(0, 0, center_z), gp_Dir(0, 0, 1))
a_plate, b_plate = self._split_mold_block_by_plane(mold_block, parting_plane)
if a_plate is not None and b_plate is not None:
cavity = self._subtract_product_from_plate(a_plate, shape, "型腔(A板)")
core = self._subtract_product_from_plate(b_plate, shape, "型芯(B板)")
if cavity is not None and core is not None:
logger.info("型腔/型芯分离完成(分型面切分+布尔减)")
return cavity, core
elif cavity is not None:
logger.warning("型芯生成失败,使用产品形状")
return cavity, shape
elif core is not None:
logger.warning("型腔生成失败,使用模具块")
return mold_block, core
logger.warning("A/B板切分不完全,回退到原方案")
return self._split_cavity_core_fallback(shape, mold_block)
except Exception as e:
logger.error(f"型腔分离失败: {e}")
return self._split_cavity_core_fallback(shape, None)
def _get_parting_plane(self, parting_surface: Any, shape: Any) -> Optional[gp_Pln]:
"""从分型面提取平面方程"""
try:
surface = BRepAdaptor_Surface(parting_surface)
if surface.GetType() == 0:
return surface.Plane()
bbox = Bnd_Box()
brepbndlib.Add(shape, bbox)
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
center_z = (zmin + zmax) / 2
return gp_Pln(gp_Pnt(0, 0, center_z), gp_Dir(0, 0, 1))
except Exception as e:
logger.warning(f"分型面平面提取失败: {e}")
return None
def _split_mold_block_by_plane(self, mold_block: Any,
parting_plane: gp_Pln) -> Tuple[Any, Any]:
"""
用分型面将模具块切分为A板(上模)和B板(下模)
方法:使用半空间体与模具块的布尔交集运算
- A板 = 模具块 ∩ 分型面上方半空间
- B板 = 模具块 ∩ 分型面下方半空间
"""
try:
plane_origin = parting_plane.Location()
plane_normal = parting_plane.Axis().Direction()
ref_point_above = gp_Pnt(
plane_origin.X() + plane_normal.X() * 10,
plane_origin.Y() + plane_normal.Y() * 10,
plane_origin.Z() + plane_normal.Z() * 10
)
ref_point_below = gp_Pnt(
plane_origin.X() - plane_normal.X() * 10,
plane_origin.Y() - plane_normal.Y() * 10,
plane_origin.Z() - plane_normal.Z() * 10
)
half_space_above = BRepPrimAPI_MakeHalfSpace(
BRepBuilderAPI_MakeFace(parting_plane).Face(),
ref_point_above
).Shape()
half_space_below = BRepPrimAPI_MakeHalfSpace(
BRepBuilderAPI_MakeFace(parting_plane).Face(),
ref_point_below
).Shape()
a_plate_op = BRepAlgoAPI_Common(mold_block, half_space_above)
a_plate = None
if a_plate_op.IsDone():
a_plate = a_plate_op.Shape()
logger.info("A板(上模)切分成功")
else:
logger.warning("A板切分失败")
b_plate_op = BRepAlgoAPI_Common(mold_block, half_space_below)
b_plate = None
if b_plate_op.IsDone():
b_plate = b_plate_op.Shape()
logger.info("B板(下模)切分成功")
else:
logger.warning("B板切分失败")
return a_plate, b_plate
except Exception as e:
logger.error(f"A/B板分离失败: {e}")
return None, None
def _subtract_product_from_plate(self, plate: Any, product: Any,
plate_name: str) -> Any:
"""从模板中减去产品形状,生成型腔或型芯"""
try:
cut_op = BRepAlgoAPI_Cut(plate, product)
if cut_op.IsDone():
result = cut_op.Shape()
logger.info(f"{plate_name}减去产品成功")
return result
else:
logger.warning(f"{plate_name}布尔减运算失败")
return plate
except Exception as e:
logger.warning(f"{plate_name}减产品失败: {e}")
return plate
def _split_cavity_core_fallback(self, shape: Any,
mold_block: Optional[Any] = None) -> Tuple[Any, Any]:
"""
分模回退方案:用边界框中心面作为分型面切分模具块。
"""
logger.warning("使用分模回退方案")
try:
bbox = Bnd_Box()
brepbndlib.Add(shape, bbox)
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
margin = 20
if mold_block is None:
mold_block = BRepPrimAPI_MakeBox(
gp_Pnt(xmin - margin, ymin - margin, zmin - margin),
gp_Pnt(xmax + margin, ymax + margin, zmax + margin)
).Shape()
center_z = (zmin + zmax) / 2
parting_plane = gp_Pln(gp_Pnt(0, 0, center_z), gp_Dir(0, 0, 1))
a_plate, b_plate = self._split_mold_block_by_plane(mold_block, parting_plane)
if a_plate is not None and b_plate is not None:
cavity = self._subtract_product_from_plate(a_plate, shape, "型腔(回退)")
core = self._subtract_product_from_plate(b_plate, shape, "型芯(回退)")
if cavity is not None and core is not None:
logger.info("回退方案型腔/型芯分离完成")
return cavity, core
cavity = self._subtract_product_from_plate(mold_block, shape, "型腔(兜底)")
return cavity or mold_block, shape
except Exception as e:
logger.error(f"分模回退方案失败: {e}")
try:
bbox = Bnd_Box()
brepbndlib.Add(shape, bbox)
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
margin = 20
cavity_block = BRepPrimAPI_MakeBox(
gp_Pnt(xmin - margin, ymin - margin, zmin - margin),
gp_Pnt(xmax + margin, ymax + margin, zmax + margin)
).Shape()
cavity = self._subtract_product_from_plate(cavity_block, shape, "型腔(兜底)")
return cavity or cavity_block, shape
except Exception:
return shape, shape
def detect_insert_regions(self, shape: Any, analysis: Dict,
depth_threshold: float = 30.0,
aspect_threshold: float = 3.0) -> List[Dict[str, Any]]:
"""
检测需要独立镶件的区域
镶件判定条件:
1. 深腔区域(深度超过阈值)
2. 细长特征(长径比超过阈值)
3. 易磨损区域(尖锐角落、薄壁)
4. 精密特征(高精度要求的局部区域)
Args:
shape: 产品形状
analysis: 几何分析结果
depth_threshold: 深腔深度阈值 mm
aspect_threshold: 长径比阈值
Returns:
镶件区域列表
"""
inserts = []
try:
bbox = analysis.get("bounding_box", {})
dims = bbox.get("dimensions", [0, 0, 0])
center = bbox.get("center", [0, 0, 0])
if dims[2] > depth_threshold:
inserts.append({
"type": "deep_cavity_insert",
"location": center,
"depth": dims[2],
"reason": f"型腔深度 {dims[2]:.1f}mm 超过阈值 {depth_threshold}mm",
"insert_type": "core_pin",
"priority": "high"
})
explorer = TopExp_Explorer(shape, TopAbs_FACE)
face_idx = 0
while explorer.More():
face = topods.Face(explorer.Current())
face_idx += 1
try:
surface = BRepAdaptor_Surface(face)
face_props = GProp_GProps()
brepgprop.SurfaceProperties(face, face_props)
area = face_props.Mass()
if area < 1.0 and area > 0.001:
bbox_face = Bnd_Box()
brepbndlib.Add(face, bbox_face)
try:
fxmin, fymin, fzmin, fxmax, fymax, fzmax = bbox_face.Get()
f_dims = [fxmax - fxmin, fymax - fymin, fzmax - fzmin]
max_dim = max(f_dims)
min_dim = min(f_dims)
if min_dim > 0.01 and max_dim / min_dim > aspect_threshold:
face_center = [
float((fxmin + fxmax) / 2),
float((fymin + fymax) / 2),
float((fzmin + fzmax) / 2)
]
inserts.append({
"type": "slender_feature_insert",
"location": face_center,
"aspect_ratio": max_dim / min_dim,
"reason": f"细长特征,长径比 {max_dim/min_dim:.1f}",
"insert_type": "core_pin",
"priority": "medium",
"face_index": face_idx
})
except Exception:
pass
if surface.GetType() == 1:
radius = surface.Cylinder().Radius()
if radius < 3.0 and radius > 0.1:
cyl_axis = surface.Cylinder().Position().Axis()
cyl_loc = cyl_axis.Location()
inserts.append({
"type": "small_hole_insert",
"location": [float(cyl_loc.X()), float(cyl_loc.Y()), float(cyl_loc.Z())],
"radius": float(radius),
"reason": f"小孔特征,半径 {radius:.2f}mm",
"insert_type": "core_pin",
"priority": "high",
"face_index": face_idx
})
except Exception:
pass
explorer.Next()
if not inserts:
logger.info("未检测到需要镶件的区域")
else:
logger.info(f"检测到 {len(inserts)} 个镶件区域")
except Exception as e:
logger.warning(f"镶件检测失败: {e}")
return inserts
def _extract_shape_geometry(self, shape: Any, shape_type: str) -> Dict[str, Any]:
try:
mesh = BRepMesh_IncrementalMesh(shape, 0.1)
mesh.Perform()
vertices = []
faces = []
explorer = TopExp_Explorer(shape, TopAbs_FACE)
vertex_index = 0
while explorer.More():
face = explorer.Current()
location = TopLoc_Location()
triangulation = BRep_Tool.Triangulation(face, location)
if triangulation:
nb_nodes = triangulation.NbNodes()
for i in range(1, nb_nodes + 1):
node = triangulation.Node(i)
transformed = node.Transformed(location.Transformation())
vertices.extend([
float(transformed.X()),
float(transformed.Y()),
float(transformed.Z())
])
nb_triangles = triangulation.NbTriangles()
for i in range(1, nb_triangles + 1):
triangle = triangulation.Triangle(i)
idx1 = triangle.Value(1) + vertex_index - 1
idx2 = triangle.Value(2) + vertex_index - 1
idx3 = triangle.Value(3) + vertex_index - 1
faces.extend([int(idx1), int(idx2), int(idx3)])
vertex_index += nb_nodes
explorer.Next()
vertex_count = len(vertices) // 3
face_count = len(faces) // 3
return {
"type": shape_type,
"vertices": vertices,
"faces": faces,
"vertex_count": vertex_count,
"face_count": face_count,
}
except Exception as e:
logger.error(f"{shape_type}几何提取失败: {e}")
return {
"type": shape_type,
"vertices": [],
"faces": [],
"vertex_count": 0,
"face_count": 0,
}
def _extract_plane_metadata(self, surface: Any) -> Dict[str, Any]:
"""从分型面提取平面元数据(法向量、原点、边界)"""
metadata = {
"normal": [0.0, 0.0, 1.0],
"origin": [0.0, 0.0, 0.0],
"bounds": {"min": [0.0, 0.0, 0.0], "max": [0.0, 0.0, 0.0]},
}
try:
surface_adaptor = BRepAdaptor_Surface(surface)
if surface_adaptor.GetType() == 0:
plane = surface_adaptor.Plane()
axis = plane.Axis()
normal = axis.Direction()
origin = plane.Location()
metadata["normal"] = [float(normal.X()), float(normal.Y()), float(normal.Z())]
metadata["origin"] = [float(origin.X()), float(origin.Y()), float(origin.Z())]
bbox = Bnd_Box()
brepbndlib.Add(surface, bbox)
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
metadata["bounds"] = {
"min": [float(xmin), float(ymin), float(zmin)],
"max": [float(xmax), float(ymax), float(zmax)],
}
except Exception as e:
logger.warning(f"提取平面元数据失败: {e}")
return metadata
def _calculate_product_weight(self, analysis: Dict) -> str:
volume_cm3 = analysis.get("volume", 0) / 1000
weight_g = volume_cm3 * self.material_density
return f"{weight_g:.2f} g"
def _assess_warpage_risk(self, analysis: Dict) -> str:
bbox = analysis.get("bounding_box", {}).get("dimensions", [1, 1, 1])
aspect_ratio = max(bbox) / min(bbox) if min(bbox) > 0 else 1
if aspect_ratio > 5:
return "高 - 建议增加加强筋"
elif aspect_ratio > 3:
return "中 - 需优化冷却"
else:
return "低"
def _get_inertia_matrix(self, props: GProp_GProps) -> List[List[float]]:
inertia = props.MatrixOfInertia()
return [
[inertia.Value(1, 1), inertia.Value(1, 2), inertia.Value(1, 3)],
[inertia.Value(2, 1), inertia.Value(2, 2), inertia.Value(2, 3)],
[inertia.Value(3, 1), inertia.Value(3, 2), inertia.Value(3, 3)]
]
def _calculate_parting_line_length(self, parting_line: List) -> float:
if not parting_line or len(parting_line) < 2:
return 0.0
total_length = 0.0
for i in range(1, len(parting_line)):
p1 = np.array(parting_line[i-1])
p2 = np.array(parting_line[i])
segment_length = np.linalg.norm(p2 - p1)
total_length += segment_length
return total_length
def _calculate_parting_line(self, shape: Any, parting_surface: Any) -> List[List[float]]:
try:
section = BRepAlgoAPI_Section(shape, parting_surface)
section.Build()
if not section.IsDone():
logger.warning("截面运算未完成,使用简化分型线")
return self._simple_parting_line(shape)
edges = []
explorer = TopExp_Explorer(section.Shape(), TopAbs_EDGE)
while explorer.More():
edge = explorer.Current()
curve = BRepAdaptor_Curve(edge)
first_param = curve.FirstParameter()
last_param = curve.LastParameter()
num_points = max(10, int((last_param - first_param) / 0.5))
step = (last_param - first_param) / num_points
for i in range(num_points + 1):
param = first_param + i * step
point = curve.Value(param)
edges.append([point.X(), point.Y(), point.Z()])
explorer.Next()
if not edges:
logger.warning("未找到交线,使用简化分型线")
return self._simple_parting_line(shape)
logger.info(f"计算得到 {len(edges)} 个分型线点")
return edges
except Exception as e:
logger.error(f"分型线计算失败: {e}")
return self._simple_parting_line(shape)
def _simple_parting_line(self, shape: Any) -> List[List[float]]:
try:
bbox = Bnd_Box()
brepbndlib.Add(shape, bbox)
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
center_z = (zmin + zmax) / 2
return [
[xmin, ymin, center_z],
[xmax, ymin, center_z],
[xmax, ymax, center_z],
[xmin, ymax, center_z],
[xmin, ymin, center_z]
]
except Exception:
return [[-50, -50, 0], [50, -50, 0], [50, 50, 0], [-50, 50, 0], [-50, -50, 0]]
def extend_parting_surface(self, parting_surface: Any, shape: Any,
extension: float = 30.0) -> Any:
"""
将分型面延伸到模具块边界
分型面通常只覆盖产品轮廓,需要延伸到模具块边缘
才能正确分离A板和B板
Args:
parting_surface: 原始分型面
shape: 产品形状
extension: 延伸距离 mm
Returns:
延伸后的分型面
"""
try:
bbox = Bnd_Box()
brepbndlib.Add(shape, bbox)
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
surface = BRepAdaptor_Surface(parting_surface)
if surface.GetType() != 0:
logger.info("分型面非平面,延伸操作跳过")
return parting_surface
plane = surface.Plane()
origin = plane.Location()
normal = plane.Axis().Direction()
extended_xmin = xmin - extension
extended_ymin = ymin - extension
extended_xmax = xmax + extension
extended_ymax = ymax + extension
extended_plane = gp_Pln(origin, normal)
extended_surface = BRepBuilderAPI_MakeFace(
extended_plane,
extended_xmin, extended_xmax,
extended_ymin, extended_ymax
).Face()
logger.info(f"分型面延伸完成: 延伸距离={extension}mm")
return extended_surface
except Exception as e:
logger.warning(f"分型面延伸失败: {e}")
return parting_surface
def optimize_parting_line(self, parting_line: List[List[float]],
smooth_window: int = 5,
min_segment_length: float = 0.5,
angle_threshold: float = 150.0) -> List[List[float]]:
"""
优化分型线
优化内容:
1. 平滑处理 - 消除噪声点
2. 去除短线段 - 合并过短的线段
3. 尖角处理 - 在尖角处添加过渡圆弧
4. 点密度均匀化 - 重采样使点间距均匀
Args:
parting_line: 原始分型线点列表
smooth_window: 平滑窗口大小
min_segment_length: 最小线段长度
angle_threshold: 尖角判定角度(度)
Returns:
优化后的分型线
"""
if len(parting_line) < 3:
return parting_line
try:
smoothed = self._smooth_parting_line(parting_line, smooth_window)
filtered = self._filter_short_segments(smoothed, min_segment_length)
optimized = self._round_sharp_corners(filtered, angle_threshold)
resampled = self._resample_parting_line(optimized, target_spacing=2.0)
logger.info(f"分型线优化: {len(parting_line)} → {len(resampled)} 点")
return resampled
except Exception as e:
logger.warning(f"分型线优化失败: {e}")
return parting_line
def _smooth_parting_line(self, points: List[List[float]],
window: int = 5) -> List[List[float]]:
"""移动平均平滑"""
if len(points) < window:
return points
arr = np.array(points, dtype=np.float64)
smoothed = []
for i in range(len(arr)):
start = max(0, i - window // 2)
end = min(len(arr), i + window // 2 + 1)
avg = np.mean(arr[start:end], axis=0)
smoothed.append(avg.tolist())
return smoothed
def _filter_short_segments(self, points: List[List[float]],
min_length: float) -> List[List[float]]:
"""去除过短线段"""
if not points:
return points
filtered = [points[0]]
for i in range(1, len(points)):
dist = np.linalg.norm(np.array(points[i]) - np.array(filtered[-1]))
if dist >= min_length:
filtered.append(points[i])
return filtered
def _round_sharp_corners(self, points: List[List[float]],
angle_threshold: float) -> List[List[float]]:
"""在尖角处添加过渡点"""
if len(points) < 3:
return points
result = [points[0]]
for i in range(1, len(points) - 1):
v1 = np.array(points[i]) - np.array(points[i - 1])
v2 = np.array(points[i + 1]) - np.array(points[i])
len1 = np.linalg.norm(v1)
len2 = np.linalg.norm(v2)
if len1 > 0.001 and len2 > 0.001:
cos_angle = np.clip(np.dot(v1, v2) / (len1 * len2), -1, 1)
angle = math.degrees(math.acos(cos_angle))
if angle < angle_threshold:
mid1 = (np.array(points[i - 1]) + np.array(points[i])) / 2
mid2 = (np.array(points[i]) + np.array(points[i + 1])) / 2
result.append(mid1.tolist())
result.append(mid2.tolist())
else:
result.append(points[i])
else:
result.append(points[i])
result.append(points[-1])
return result
def _resample_parting_line(self, points: List[List[float]],
target_spacing: float) -> List[List[float]]:
"""重采样使点间距均匀"""
if len(points) < 2:
return points
arr = np.array(points, dtype=np.float64)
cumulative_dist = [0.0]
for i in range(1, len(arr)):
dist = np.linalg.norm(arr[i] - arr[i - 1])
cumulative_dist.append(cumulative_dist[-1] + dist)
total_length = cumulative_dist[-1]
if total_length < target_spacing:
return points
num_points = max(3, int(total_length / target_spacing))
new_distances = np.linspace(0, total_length, num_points)
resampled = []
for d in new_distances:
idx = np.searchsorted(cumulative_dist, d) - 1
idx = max(0, min(idx, len(arr) - 2))
seg_start = cumulative_dist[idx]
seg_end = cumulative_dist[idx + 1]
seg_length = seg_end - seg_start
if seg_length > 0:
t = (d - seg_start) / seg_length
else:
t = 0
point = arr[idx] + t * (arr[idx + 1] - arr[idx])
resampled.append(point.tolist())
return resampled
-256
View File
@@ -1,256 +0,0 @@
import asyncio
import sys
from pathlib import Path
from sqlalchemy import text
project_root = Path(__file__).parent.parent.parent
src_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root))
sys.path.insert(0, str(src_root))
from sqlalchemy import select
from database.database import db_manager
from models.database import User, Role, Permission, UserRole, RolePermission
from services.auth_service import get_password_hash
from config.settings import settings
from utils.logger import get_logger
logger = get_logger(__name__)
DEFAULT_PERMISSIONS = [
{"code": "view_dashboard", "name": "查看仪表盘", "module": "dashboard"},
{"code": "view_moldinsight", "name": "使用模具分析", "module": "moldinsight"},
{"code": "upload_file", "name": "上传文件", "module": "moldinsight"},
{"code": "view_history", "name": "查看历史记录", "module": "moldinsight"},
{"code": "view_inventory", "name": "查看库存", "module": "inventory"},
{"code": "manage_inventory", "name": "管理库存", "module": "inventory"},
{"code": "view_products", "name": "查看产品", "module": "inventory"},
{"code": "manage_products", "name": "管理产品", "module": "inventory"},
{"code": "view_suppliers", "name": "查看供应商", "module": "inventory"},
{"code": "manage_suppliers", "name": "管理供应商", "module": "inventory"},
{"code": "view_customers", "name": "查看客户", "module": "inventory"},
{"code": "manage_customers", "name": "管理客户", "module": "inventory"},
{"code": "view_finance", "name": "查看财务", "module": "finance"},
{"code": "manage_receipts", "name": "管理收款", "module": "finance"},
{"code": "manage_payments", "name": "管理付款", "module": "finance"},
{"code": "void_finance_transaction", "name": "作废财务单据", "module": "finance"},
{"code": "view_users", "name": "查看用户", "module": "admin"},
{"code": "manage_users", "name": "管理用户", "module": "admin"},
{"code": "manage_roles", "name": "管理角色", "module": "admin"},
]
DEFAULT_ROLES = [
{"code": "admin", "name": "管理员", "description": "系统管理员,拥有所有权限", "is_system": True, "permissions": ["view_dashboard", "view_moldinsight", "upload_file", "view_history", "view_inventory", "manage_inventory", "view_products", "manage_products", "view_suppliers", "manage_suppliers", "view_customers", "manage_customers", "view_finance", "manage_receipts", "manage_payments", "void_finance_transaction", "view_users", "manage_users", "manage_roles"]},
{"code": "user", "name": "普通用户", "description": "普通用户,可使用模具分析和查看库存", "is_system": False, "permissions": ["view_dashboard", "view_moldinsight", "upload_file", "view_history", "view_inventory", "view_products", "view_suppliers", "view_customers", "view_finance", "manage_receipts", "manage_payments"]},
{"code": "viewer", "name": "只读用户", "description": "只读用户,只能查看数据", "is_system": False, "permissions": ["view_dashboard", "view_moldinsight", "view_history", "view_inventory", "view_products", "view_suppliers", "view_customers", "view_finance"]},
]
async def init_permissions(session):
"""初始化权限"""
result = await session.execute(select(Permission))
existing_perms = result.scalars().all()
if existing_perms:
logger.info("权限已初始化")
return
perm_map = {}
for perm_data in DEFAULT_PERMISSIONS:
perm = Permission(**perm_data)
session.add(perm)
await session.flush()
perm_map[perm.code] = perm.id
logger.info(f"创建了 {len(DEFAULT_PERMISSIONS)} 个权限")
return perm_map
async def init_roles(session, perm_map):
"""初始化角色"""
result = await session.execute(select(Role))
existing_roles = result.scalars().all()
if existing_roles:
logger.info("角色已初始化")
return
for role_data in DEFAULT_ROLES:
perm_ids = [perm_map[code] for code in role_data.pop("permissions")]
role = Role(**role_data)
session.add(role)
await session.flush()
for perm_id in perm_ids:
rp = RolePermission(role_id=role.id, permission_id=perm_id)
session.add(rp)
logger.info(f"创建了 {len(DEFAULT_ROLES)} 个角色")
async def create_admin_user(session):
"""创建默认管理员"""
result = await session.execute(select(User).where(User.username == settings.ADMIN_USERNAME))
existing_admin = result.scalar_one_or_none()
if existing_admin:
logger.info("管理员账户已存在")
return
admin = User(
username=settings.ADMIN_USERNAME,
email=settings.ADMIN_EMAIL,
hashed_password=get_password_hash(settings.ADMIN_PASSWORD),
full_name=settings.ADMIN_FULL_NAME,
is_active=True
)
session.add(admin)
await session.flush()
result = await session.execute(select(Role).where(Role.code == "admin"))
admin_role = result.scalar_one_or_none()
if admin_role:
user_role = UserRole(user_id=admin.id, role_id=admin_role.id)
session.add(user_role)
await session.commit()
logger.info(f"创建了管理员账户: {settings.ADMIN_USERNAME}")
async def init_database(keep_connected: bool = True):
"""初始化数据库"""
try:
await db_manager.connect()
await db_manager.create_tables()
await ensure_schema_updates()
async with db_manager.session() as session:
perm_map = await init_permissions(session)
if perm_map is None:
# Permissions already existed, fetch them from database
result = await session.execute(select(Permission))
perms = result.scalars().all()
perm_map = {perm.code: perm.id for perm in perms}
await init_roles(session, perm_map)
await create_admin_user(session)
logger.info("数据库初始化完成")
print("=" * 60)
print("数据库初始化成功!")
print("=" * 60)
print(f"管理员用户名: {settings.ADMIN_USERNAME}")
print(f"管理员邮箱: {settings.ADMIN_EMAIL}")
print("=" * 60)
print("可以在 .env 文件中修改管理员配置:")
print(" ADMIN_USERNAME")
print(" ADMIN_EMAIL")
print(" ADMIN_FULL_NAME")
print("=" * 60)
return True
except Exception as e:
logger.error(f"数据库初始化失败: {e}")
print(f"数据库初始化失败: {e}")
return False
finally:
if not keep_connected:
await db_manager.disconnect()
async def ensure_schema_updates():
async with db_manager.engine.begin() as conn:
await conn.execute(text("ALTER TABLE products ADD COLUMN IF NOT EXISTS item_type VARCHAR(20) DEFAULT 'finished'"))
await conn.execute(text("UPDATE products SET item_type = 'finished' WHERE item_type IS NULL"))
await conn.execute(text("ALTER TABLE sales_orders ADD COLUMN IF NOT EXISTS production_status VARCHAR(20) DEFAULT 'not_started'"))
await conn.execute(text("ALTER TABLE sales_orders ADD COLUMN IF NOT EXISTS production_no VARCHAR(50)"))
await conn.execute(text("ALTER TABLE sales_orders ADD COLUMN IF NOT EXISTS planned_material_cost DOUBLE PRECISION DEFAULT 0"))
await conn.execute(text("ALTER TABLE sales_orders ADD COLUMN IF NOT EXISTS actual_material_cost DOUBLE PRECISION DEFAULT 0"))
await conn.execute(text("ALTER TABLE purchase_orders ADD COLUMN IF NOT EXISTS received_date TIMESTAMP WITHOUT TIME ZONE"))
await conn.execute(text("ALTER TABLE purchase_orders ADD COLUMN IF NOT EXISTS paid_date TIMESTAMP WITHOUT TIME ZONE"))
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS product_materials (
id SERIAL PRIMARY KEY,
finished_product_id INTEGER NOT NULL REFERENCES products(id),
material_product_id INTEGER NOT NULL REFERENCES products(id),
quantity DOUBLE PRECISION NOT NULL,
loss_rate DOUBLE PRECISION DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
)
"""))
await conn.execute(text("""
CREATE UNIQUE INDEX IF NOT EXISTS uq_product_material_unique
ON product_materials (finished_product_id, material_product_id)
"""))
await conn.execute(text("ALTER TABLE mold_cavity_data ADD COLUMN IF NOT EXISTS best_scheme_id VARCHAR(64)"))
await conn.execute(text("ALTER TABLE mold_cavity_data ADD COLUMN IF NOT EXISTS confidence_score DOUBLE PRECISION"))
await conn.execute(text("ALTER TABLE mold_cavity_data ADD COLUMN IF NOT EXISTS is_fallback BOOLEAN"))
await conn.execute(text("ALTER TABLE mold_cavity_data ADD COLUMN IF NOT EXISTS fallback_reason TEXT"))
await conn.execute(text("""
CREATE INDEX IF NOT EXISTS idx_mold_cavity_best_scheme_id
ON mold_cavity_data (best_scheme_id)
"""))
await conn.execute(text("""
CREATE INDEX IF NOT EXISTS idx_mold_cavity_is_fallback
ON mold_cavity_data (is_fallback)
"""))
await conn.execute(text("""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'uq_inventory_product_warehouse'
) THEN
ALTER TABLE inventory
ADD CONSTRAINT uq_inventory_product_warehouse UNIQUE (product_id, warehouse_id);
END IF;
END $$;
"""))
await conn.execute(text("""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'ck_inventory_qty_nonnegative'
) THEN
ALTER TABLE inventory
ADD CONSTRAINT ck_inventory_qty_nonnegative
CHECK (quantity >= 0 AND locked_quantity >= 0 AND locked_quantity <= quantity);
END IF;
END $$;
"""))
await conn.execute(text("""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'ck_purchase_order_items_qty'
) THEN
ALTER TABLE purchase_order_items
ADD CONSTRAINT ck_purchase_order_items_qty
CHECK (quantity > 0 AND received_quantity >= 0 AND received_quantity <= quantity);
END IF;
END $$;
"""))
await conn.execute(text("""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'ck_sales_order_items_qty'
) THEN
ALTER TABLE sales_order_items
ADD CONSTRAINT ck_sales_order_items_qty
CHECK (quantity > 0 AND delivered_quantity >= 0 AND delivered_quantity <= quantity);
END IF;
END $$;
"""))
await conn.execute(text("ALTER TABLE sales_orders ADD COLUMN IF NOT EXISTS manufacturing_date TIMESTAMP WITHOUT TIME ZONE"))
await conn.execute(text("ALTER TABLE sales_orders ALTER COLUMN manufacturing_date TYPE TIMESTAMP WITHOUT TIME ZONE"))
if __name__ == "__main__":
asyncio.run(init_database(keep_connected=False))
-874
View File
@@ -1,874 +0,0 @@
# models/database.py
from sqlalchemy import Column, Integer, String, Text, DateTime, Date, JSON, LargeBinary, Boolean, Float, ForeignKey, UniqueConstraint, Numeric
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from datetime import datetime, date
Base = declarative_base()
class User(Base):
"""用户表"""
__tablename__ = "users"
__excluded_fields__ = {'hashed_password'}
id = Column(Integer, primary_key=True, index=True)
username = Column(String(50), unique=True, index=True, nullable=False)
email = Column(String(255), unique=True, index=True, nullable=False)
hashed_password = Column(String(255), nullable=False)
full_name = Column(String(100))
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=func.now())
last_login = Column(DateTime, nullable=True)
stp_files = relationship("STPFile", back_populates="user")
user_roles = relationship("UserRole", back_populates="user", cascade="all, delete-orphan")
@property
def roles(self):
return [ur.role for ur in self.user_roles]
@property
def is_superuser(self):
return any(r.code == 'admin' for r in self.roles)
def has_permission(self, permission_code: str) -> bool:
if self.is_superuser:
return True
for role in self.roles:
for perm in role.permissions:
if perm.code == permission_code:
return True
return False
def safe_dict(self):
return {k: v for k, v in self.__dict__.items()
if not k.startswith('_') and k not in self.__excluded_fields__}
def __repr__(self):
return f"<User(id={self.id}, username='{self.username}')>"
class Role(Base):
"""角色表"""
__tablename__ = "roles"
id = Column(Integer, primary_key=True, index=True)
code = Column(String(50), unique=True, index=True, nullable=False)
name = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
is_system = Column(Boolean, default=False)
created_at = Column(DateTime, default=func.now())
user_roles = relationship("UserRole", back_populates="role", cascade="all, delete-orphan")
role_permissions = relationship("RolePermission", back_populates="role", cascade="all, delete-orphan")
@property
def permissions(self):
return [rp.permission for rp in self.role_permissions]
def __repr__(self):
return f"<Role(code='{self.code}', name='{self.name}')>"
class Permission(Base):
"""权限表"""
__tablename__ = "permissions"
id = Column(Integer, primary_key=True, index=True)
code = Column(String(100), unique=True, index=True, nullable=False)
name = Column(String(100), nullable=False)
module = Column(String(50), nullable=True)
description = Column(Text, nullable=True)
created_at = Column(DateTime, default=func.now())
role_permissions = relationship("RolePermission", back_populates="permission", cascade="all, delete-orphan")
def __repr__(self):
return f"<Permission(code='{self.code}', name='{self.name}')>"
class UserRole(Base):
"""用户角色关联表"""
__tablename__ = "user_roles"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
role_id = Column(Integer, ForeignKey("roles.id"), nullable=False, index=True)
created_at = Column(DateTime, default=func.now())
user = relationship("User", back_populates="user_roles")
role = relationship("Role", back_populates="user_roles")
def __repr__(self):
return f"<UserRole(user_id={self.user_id}, role_id={self.role_id})>"
class RolePermission(Base):
"""角色权限关联表"""
__tablename__ = "role_permissions"
id = Column(Integer, primary_key=True, index=True)
role_id = Column(Integer, ForeignKey("roles.id"), nullable=False, index=True)
permission_id = Column(Integer, ForeignKey("permissions.id"), nullable=False, index=True)
created_at = Column(DateTime, default=func.now())
role = relationship("Role", back_populates="role_permissions")
permission = relationship("Permission", back_populates="role_permissions")
def __repr__(self):
return f"<RolePermission(role_id={self.role_id}, permission_id={self.permission_id})>"
class STPFile(Base):
"""STP源文件元数据表 - 支持同一文件多次上传"""
__tablename__ = "stp_files"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
# 对象存储信息
object_key = Column(String(500), nullable=False, index=True) # MinIO对象键
storage_bucket = Column(String(100), nullable=False) # 存储桶名称
object_url = Column(String(1000), nullable=True) # 预签名URL(可选)
# 文件信息
original_filename = Column(String(255), nullable=False, index=True) # 添加索引支持按文件名查询
file_size = Column(Integer, nullable=False)
file_hash = Column(String(64), index=True) # 移除unique约束,允许同一文件多次上传
mime_type = Column(String(50), default="application/octet-stream")
# 上传批次标识 - 用于区分同一文件的多次上传
upload_batch = Column(String(36), index=True) # UUID批次号
# 时间戳
upload_time = Column(DateTime, default=func.now())
processed_time = Column(DateTime, nullable=True)
# 状态
status = Column(String(20), default="pending", index=True) # pending, processing, completed, failed
error_message = Column(Text, nullable=True)
# 分析摘要 - 快速查询字段
volume = Column(Float, nullable=True) # 体积 mm³
surface_area = Column(Float, nullable=True) # 表面积 mm²
product_weight = Column(Float, nullable=True) # 产品重量 g
# 保留旧字段以兼容
file_path = Column(String(500), nullable=True) # 本地路径(已弃用)
file_content = Column(LargeBinary, nullable=True) # 本地存储(已弃用)
filename = Column(String(255), nullable=True) # 已弃用
# 关联关系
user = relationship("User", back_populates="stp_files")
geometry_data = relationship("GeometryData", back_populates="stp_file", uselist=False)
mesh_data = relationship("MeshData", back_populates="stp_file", uselist=False)
mold_cavity_data = relationship("MoldCavityData", back_populates="stp_file", uselist=False)
html_file = relationship("HTMLFile", back_populates="stp_file", uselist=False)
analysis_metrics = relationship("AnalysisMetrics", back_populates="stp_file", uselist=False)
feature_detections = relationship("FeatureDetection", back_populates="stp_file")
design_recommendations = relationship("DesignRecommendation", back_populates="stp_file")
processing_tasks = relationship("ProcessingTask", back_populates="stp_file")
def __repr__(self):
return f"<STPFile(id={self.id}, original_filename='{self.original_filename}', status='{self.status}')>"
class GeometryData(Base):
"""几何数据JSON元数据表"""
__tablename__ = "geometry_data"
id = Column(Integer, primary_key=True, index=True)
stp_file_id = Column(Integer, ForeignKey("stp_files.id"), nullable=False, index=True)
# 对象存储信息
object_key = Column(String(500), nullable=False)
storage_bucket = Column(String(100), nullable=False)
object_url = Column(String(1000), nullable=True)
# 分析方法
analysis_method = Column(String(50), default="pythonocc") # pythonocc, simulated
# 时间戳
created_time = Column(DateTime, default=func.now())
# 几何属性摘要(便于快速查询)
volume = Column(Float, nullable=True)
surface_area = Column(Float, nullable=True)
bounding_box_min = Column(JSON, nullable=True)
bounding_box_max = Column(JSON, nullable=True)
center_of_mass = Column(JSON, nullable=True)
# 拓扑信息
topology_faces = Column(Integer, nullable=True)
topology_edges = Column(Integer, nullable=True)
topology_vertices = Column(Integer, nullable=True)
# 关联关系
stp_file = relationship("STPFile", back_populates="geometry_data")
def __repr__(self):
return f"<GeometryData(id={self.id}, stp_file_id={self.stp_file_id})>"
class MeshData(Base):
"""网格数据JSON元数据表(详细网格存 RustFS,PostgreSQL 存摘要)"""
__tablename__ = "mesh_data"
id = Column(Integer, primary_key=True, index=True)
stp_file_id = Column(Integer, ForeignKey("stp_files.id"), nullable=False, index=True)
# 对象存储信息
object_key = Column(String(500), nullable=False)
storage_bucket = Column(String(100), nullable=False)
object_url = Column(String(1000), nullable=True)
# 生成设置
quality = Column(String(20), default="medium") # low / medium / high
# 网格规模信息
vertex_count = Column(Integer, nullable=True)
face_count = Column(Integer, nullable=True)
point_count = Column(Integer, nullable=True) # 采样点云数量
# 网格边界框(便于快速查询)
bounding_box_min = Column(JSON, nullable=True)
bounding_box_max = Column(JSON, nullable=True)
# 时间戳
created_time = Column(DateTime, default=func.now())
# 关联关系
stp_file = relationship("STPFile", back_populates="mesh_data")
def __repr__(self):
return f"<MeshData(id={self.id}, stp_file_id={self.stp_file_id}, quality='{self.quality}')>"
class HTMLFile(Base):
"""网页文件元数据表"""
__tablename__ = "html_files"
id = Column(Integer, primary_key=True, index=True)
stp_file_id = Column(Integer, ForeignKey("stp_files.id"), nullable=False, index=True)
# 对象存储信息
object_key = Column(String(500), nullable=False)
storage_bucket = Column(String(100), nullable=False)
object_url = Column(String(1000), nullable=True)
# 文件信息
filename = Column(String(255), nullable=False)
generated_time = Column(DateTime, default=func.now())
# 可视化相关元数据
visualization_type = Column(String(50), default="3d_viewer")
has_interactive_elements = Column(Boolean, default=True)
# 保留旧字段以兼容
file_path = Column(String(500), nullable=True)
html_content = Column(Text, nullable=True)
# 关联关系
stp_file = relationship("STPFile", back_populates="html_file")
def __repr__(self):
return f"<HTMLFile(id={self.id}, stp_file_id={self.stp_file_id}, object_key='{self.object_key}')>"
class ProcessingTask(Base):
"""处理任务记录表"""
__tablename__ = "processing_tasks"
id = Column(Integer, primary_key=True, index=True)
task_id = Column(String(36), unique=True, index=True, nullable=False)
stp_file_id = Column(Integer, ForeignKey("stp_files.id"), nullable=False, index=True)
# 任务类型和状态
task_type = Column(String(50), default="stp_parsing") # stp_parsing, geometry_analysis, mold_generation
status = Column(String(20), default="pending") # pending, processing, completed, failed
# 时间戳
created_time = Column(DateTime, default=func.now())
started_time = Column(DateTime, nullable=True)
completed_time = Column(DateTime, nullable=True)
# 处理进度
progress = Column(Integer, default=0) # 0-100
current_step = Column(String(100), nullable=True)
# 错误信息
error_message = Column(Text, nullable=True)
error_stack = Column(Text, nullable=True)
# 处理参数
parameters = Column(JSON, nullable=True) # 任务参数
# 关联关系
stp_file = relationship("STPFile", back_populates="processing_tasks")
def __repr__(self):
return f"<ProcessingTask(id={self.id}, task_id='{self.task_id}', status='{self.status}')>"
class MoldCavityData(Base):
"""模具型腔数据元数据表"""
__tablename__ = "mold_cavity_data"
id = Column(Integer, primary_key=True, index=True)
stp_file_id = Column(Integer, ForeignKey("stp_files.id"), nullable=False, index=True)
# 对象存储信息
detailed_object_key = Column(String(500), nullable=False) # 完整三维数据
storage_bucket = Column(String(100), nullable=False)
# 模具类型和材料
mold_material = Column(String(100), default="Aluminum Alloy 7075")
mold_type = Column(String(50), default="single_cavity") # single_cavity, multi_cavity
# 工艺参数
shrinkage_rate = Column(Float, nullable=False)
draft_angle = Column(Float, nullable=False)
parting_line_length = Column(Float, nullable=True)
# 生成时间
generated_time = Column(DateTime, default=func.now())
# 关键信息摘要(快速查询字段)
cavity_key_info = Column(JSON, nullable=True) # 完整关键信息
# 提取的字段(便于查询和排序)
mold_size_length = Column(Float, nullable=True)
mold_size_width = Column(Float, nullable=True)
mold_size_height = Column(Float, nullable=True)
estimated_clamping_force = Column(String(50), nullable=True)
product_weight = Column(String(50), nullable=True)
product_volume = Column(Float, nullable=True)
wall_thickness_range = Column(String(50), nullable=True)
complexity_score = Column(Float, nullable=True)
# 质量评估
weld_line_risk = Column(String(50), nullable=True) # 熔接痕风险
sink_mark_risk = Column(String(50), nullable=True) # 缩痕风险
warpage_risk = Column(String(50), nullable=True) # 翘曲风险
# 多方案可信化摘要(第1周阶段1)
best_scheme_id = Column(String(64), nullable=True, index=True)
confidence_score = Column(Float, nullable=True)
is_fallback = Column(Boolean, nullable=True, index=True)
fallback_reason = Column(Text, nullable=True)
# 关联关系
stp_file = relationship("STPFile", back_populates="mold_cavity_data")
def __repr__(self):
return f"<MoldCavityData(stp_file_id={self.stp_file_id}, mold_material='{self.mold_material}')>"
class FeatureDetection(Base):
"""特征检测结果表"""
__tablename__ = "feature_detections"
id = Column(Integer, primary_key=True, index=True)
stp_file_id = Column(Integer, ForeignKey("stp_files.id"), nullable=False, index=True)
# 特征信息
feature_type = Column(String(50), nullable=False, index=True) # thin_wall, thick_wall, wall_non_uniform, rib, boss, draft_angle, high_curvature, fillet
confidence = Column(Float, nullable=False) # 0.0 - 1.0
# 位置和尺寸
location = Column(JSON, nullable=True) # [x, y, z]
dimensions = Column(JSON, nullable=True) # [length, width, height]
# 特征参数
parameters = Column(JSON, nullable=True) # 自定义参数
# 检测时间
detected_at = Column(DateTime, default=func.now())
# 关联的几何数据
geometry_data_id = Column(Integer, ForeignKey("geometry_data.id"), nullable=True)
# 关联关系
stp_file = relationship("STPFile", back_populates="feature_detections")
def __repr__(self):
return f"<FeatureDetection(id={self.id}, feature_type='{self.feature_type}', confidence={self.confidence})>"
class DesignRecommendation(Base):
"""设计建议表"""
__tablename__ = "design_recommendations"
id = Column(Integer, primary_key=True, index=True)
stp_file_id = Column(Integer, ForeignKey("stp_files.id"), nullable=False, index=True)
# 建议信息
rec_type = Column(String(50), nullable=False) # wall_thickness, draft_angle, etc.
priority = Column(String(20), nullable=False) # high, medium, low
description = Column(String(500), nullable=False)
reason = Column(Text, nullable=True)
# 建议参数
parameters = Column(JSON, nullable=True)
# 状态
status = Column(String(20), default="pending") # pending, accepted, rejected
user_notes = Column(Text, nullable=True)
# 时间戳
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, nullable=True)
# 关联关系
stp_file = relationship("STPFile", back_populates="design_recommendations")
def __repr__(self):
return f"<DesignRecommendation(id={self.id}, rec_type='{self.rec_type}', priority='{self.priority}')>"
class UserActivity(Base):
"""用户活动日志表"""
__tablename__ = "user_activities"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
# 活动信息
activity_type = Column(String(50), nullable=False, index=True) # upload, view, download, delete, export
resource_type = Column(String(50), nullable=True) # stp_file, geometry_data, mold_cavity
resource_id = Column(Integer, nullable=True)
# 活动详情
description = Column(Text, nullable=True)
meta_data = Column(JSON, nullable=True)
# 时间戳
created_at = Column(DateTime, default=func.now(), index=True)
# IP和设备信息
ip_address = Column(String(45), nullable=True)
user_agent = Column(String(500), nullable=True)
def __repr__(self):
return f"<UserActivity(id={self.id}, user_id={self.user_id}, activity_type='{self.activity_type}')>"
class SystemLog(Base):
"""系统日志表(重要操作和错误)"""
__tablename__ = "system_logs"
id = Column(Integer, primary_key=True, index=True)
# 日志级别
level = Column(String(20), nullable=False, index=True) # INFO, WARNING, ERROR, CRITICAL
# 日志信息
message = Column(Text, nullable=False)
module = Column(String(100), nullable=True) # 模块名
function_name = Column(String(100), nullable=True)
# 时间戳
created_at = Column(DateTime, default=func.now(), index=True)
# 用户信息(如果有关联用户)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
# 额外信息
request_id = Column(String(100), nullable=True) # 关联的请求ID
execution_time_ms = Column(Integer, nullable=True) # 执行时间
# 关联数据
resource_type = Column(String(50), nullable=True)
resource_id = Column(Integer, nullable=True)
def __repr__(self):
return f"<SystemLog(id={self.id}, level='{self.level}', module='{self.module}')>"
class Product(Base):
"""产品表"""
__tablename__ = "products"
id = Column(Integer, primary_key=True, index=True)
sku = Column(String(50), unique=True, index=True, nullable=False)
name = Column(String(200), nullable=False)
description = Column(Text, nullable=True)
category = Column(String(100), nullable=True)
unit = Column(String(20), default="件")
item_type = Column(String(20), default="finished", index=True)
cost_price = Column(Numeric(12, 2), default=0)
sale_price = Column(Numeric(12, 2), default=0)
min_stock = Column(Integer, default=0)
max_stock = Column(Integer, default=1000)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
inventory = relationship("Inventory", back_populates="product", uselist=False)
stock_movements = relationship("StockMovement", back_populates="product")
bom_materials = relationship(
"ProductMaterial",
foreign_keys="ProductMaterial.finished_product_id",
back_populates="finished_product",
cascade="all, delete-orphan"
)
used_in_products = relationship(
"ProductMaterial",
foreign_keys="ProductMaterial.material_product_id",
back_populates="material_product"
)
def __repr__(self):
return f"<Product(id={self.id}, sku='{self.sku}', name='{self.name}')>"
class ProductMaterial(Base):
__tablename__ = "product_materials"
__table_args__ = (
UniqueConstraint("finished_product_id", "material_product_id", name="uq_product_material_unique"),
)
id = Column(Integer, primary_key=True, index=True)
finished_product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
material_product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
quantity = Column(Numeric(12, 4), nullable=False)
loss_rate = Column(Numeric(5, 4), default=0)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
finished_product = relationship(
"Product",
foreign_keys=[finished_product_id],
back_populates="bom_materials"
)
material_product = relationship(
"Product",
foreign_keys=[material_product_id],
back_populates="used_in_products"
)
def __repr__(self):
return f"<ProductMaterial(finished_product_id={self.finished_product_id}, material_product_id={self.material_product_id})>"
class MaterialPriceHistory(Base):
"""物料价格历史表"""
__tablename__ = "material_price_history"
id = Column(Integer, primary_key=True, index=True)
product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
price = Column(Numeric(12, 2), nullable=False)
effective_date = Column(DateTime, default=func.now(), index=True)
supplier_id = Column(Integer, ForeignKey("suppliers.id"), nullable=True, index=True)
remark = Column(Text, nullable=True)
created_at = Column(DateTime, default=func.now())
product = relationship("Product", backref="price_history")
supplier = relationship("Supplier", backref="price_history")
def __repr__(self):
return f"<MaterialPriceHistory(product_id={self.product_id}, price={self.price}, date={self.effective_date})>"
class MaterialSupplier(Base):
"""物料供应商关联表"""
__tablename__ = "material_suppliers"
id = Column(Integer, primary_key=True, index=True)
product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
supplier_id = Column(Integer, ForeignKey("suppliers.id"), nullable=False, index=True)
is_primary = Column(Boolean, default=False)
contact_person = Column(String(100), nullable=True)
contact_phone = Column(String(50), nullable=True)
lead_time = Column(Integer, nullable=True) # 交货周期(天)
min_order_quantity = Column(Integer, nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
product = relationship("Product", backref="suppliers")
supplier = relationship("Supplier", backref="materials")
def __repr__(self):
return f"<MaterialSupplier(product_id={self.product_id}, supplier_id={self.supplier_id}, primary={self.is_primary})>"
class Supplier(Base):
"""供应商表"""
__tablename__ = "suppliers"
id = Column(Integer, primary_key=True, index=True)
code = Column(String(50), unique=True, index=True)
name = Column(String(200), nullable=False)
contact_person = Column(String(100), nullable=True)
phone = Column(String(50), nullable=True)
email = Column(String(100), nullable=True)
address = Column(Text, nullable=True)
bank_name = Column(String(100), nullable=True)
bank_account = Column(String(50), nullable=True)
tax_number = Column(String(50), nullable=True)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
purchase_orders = relationship("PurchaseOrder", back_populates="supplier")
def __repr__(self):
return f"<Supplier(id={self.id}, name='{self.name}')>"
class Customer(Base):
"""客户表"""
__tablename__ = "customers"
id = Column(Integer, primary_key=True, index=True)
code = Column(String(50), unique=True, index=True)
name = Column(String(200), nullable=False)
contact_person = Column(String(100), nullable=True)
phone = Column(String(50), nullable=True)
email = Column(String(100), nullable=True)
address = Column(Text, nullable=True)
bank_name = Column(String(100), nullable=True)
bank_account = Column(String(50), nullable=True)
tax_number = Column(String(50), nullable=True)
credit_limit = Column(Numeric(12, 2), default=0)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
sales_orders = relationship("SalesOrder", back_populates="customer")
def __repr__(self):
return f"<Customer(id={self.id}, name='{self.name}')>"
class Warehouse(Base):
"""仓库表"""
__tablename__ = "warehouses"
id = Column(Integer, primary_key=True, index=True)
code = Column(String(50), unique=True, index=True)
name = Column(String(200), nullable=False)
address = Column(Text, nullable=True)
manager = Column(String(100), nullable=True)
phone = Column(String(50), nullable=True)
is_active = Column(Boolean, default=True)
is_default = Column(Boolean, default=False)
created_at = Column(DateTime, default=func.now())
inventories = relationship("Inventory", back_populates="warehouse")
def __repr__(self):
return f"<Warehouse(id={self.id}, name='{self.name}')>"
class Inventory(Base):
"""库存表"""
__tablename__ = "inventory"
id = Column(Integer, primary_key=True, index=True)
product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
warehouse_id = Column(Integer, ForeignKey("warehouses.id"), nullable=False, index=True)
quantity = Column(Numeric(12, 4), default=0)
locked_quantity = Column(Numeric(12, 4), default=0)
batch_number = Column(String(50), nullable=True)
location = Column(String(100), nullable=True)
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
product = relationship("Product", back_populates="inventory")
warehouse = relationship("Warehouse", back_populates="inventories")
def __repr__(self):
return f"<Inventory(product_id={self.product_id}, quantity={self.quantity})>"
@property
def available_quantity(self):
return self.quantity - self.locked_quantity
class StockMovement(Base):
"""库存变动记录表"""
__tablename__ = "stock_movements"
id = Column(Integer, primary_key=True, index=True)
product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
warehouse_id = Column(Integer, ForeignKey("warehouses.id"), nullable=False)
movement_type = Column(String(20), nullable=False)
quantity = Column(Numeric(12, 4), nullable=False)
before_quantity = Column(Numeric(12, 4), default=0)
after_quantity = Column(Numeric(12, 4), default=0)
reference_type = Column(String(50), nullable=True)
reference_id = Column(Integer, nullable=True)
reference_no = Column(String(50), nullable=True)
unit_price = Column(Numeric(12, 2), nullable=True)
total_amount = Column(Numeric(12, 2), nullable=True)
remark = Column(Text, nullable=True)
operator_id = Column(Integer, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime, default=func.now(), index=True)
product = relationship("Product", back_populates="stock_movements")
def __repr__(self):
return f"<StockMovement(id={self.id}, type='{self.movement_type}', qty={self.quantity})>"
class PurchaseOrder(Base):
"""采购订单表"""
__tablename__ = "purchase_orders"
id = Column(Integer, primary_key=True, index=True)
order_no = Column(String(50), unique=True, index=True, nullable=False)
supplier_id = Column(Integer, ForeignKey("suppliers.id"), nullable=False, index=True)
order_date = Column(DateTime, default=func.now())
expected_date = Column(Date, nullable=True)
status = Column(String(20), default="draft")
total_amount = Column(Numeric(12, 2), default=0)
paid_amount = Column(Numeric(12, 2), default=0)
remark = Column(Text, nullable=True)
operator_id = Column(Integer, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
# 状态变更时间
received_date = Column(DateTime, nullable=True) # 已收货时间
paid_date = Column(DateTime, nullable=True) # 已付款时间
supplier = relationship("Supplier", back_populates="purchase_orders")
items = relationship("PurchaseOrderItem", back_populates="order", cascade="all, delete-orphan")
def __repr__(self):
return f"<PurchaseOrder(order_no='{self.order_no}', status='{self.status}')>"
class PurchaseOrderItem(Base):
"""采购订单明细表"""
__tablename__ = "purchase_order_items"
id = Column(Integer, primary_key=True, index=True)
order_id = Column(Integer, ForeignKey("purchase_orders.id"), nullable=False)
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
quantity = Column(Integer, nullable=False)
received_quantity = Column(Integer, default=0)
unit_price = Column(Numeric(12, 2), nullable=False)
amount = Column(Numeric(12, 2), nullable=False)
remark = Column(Text, nullable=True)
order = relationship("PurchaseOrder", back_populates="items")
def __repr__(self):
return f"<PurchaseOrderItem(order_id={self.order_id}, product_id={self.product_id})>"
class SalesOrder(Base):
"""销售订单表"""
__tablename__ = "sales_orders"
id = Column(Integer, primary_key=True, index=True)
order_no = Column(String(50), unique=True, index=True, nullable=False)
customer_id = Column(Integer, ForeignKey("customers.id"), nullable=False, index=True)
order_date = Column(DateTime, default=func.now())
delivery_date = Column(Date, nullable=True)
manufacturing_date = Column(DateTime, nullable=True)
actual_delivery_date = Column(DateTime, nullable=True)
actual_payment_date = Column(DateTime, nullable=True)
status = Column(String(20), default="draft")
production_status = Column(String(20), default="not_started", index=True)
production_no = Column(String(50), nullable=True, index=True)
planned_material_cost = Column(Numeric(12, 2), default=0)
actual_material_cost = Column(Numeric(12, 2), default=0)
total_amount = Column(Numeric(12, 2), default=0)
received_amount = Column(Numeric(12, 2), default=0)
remark = Column(Text, nullable=True)
operator_id = Column(Integer, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
customer = relationship("Customer", back_populates="sales_orders")
items = relationship("SalesOrderItem", back_populates="order", cascade="all, delete-orphan")
def __repr__(self):
return f"<SalesOrder(order_no='{self.order_no}', status='{self.status}')>"
class FinanceTransaction(Base):
__tablename__ = "finance_transactions"
id = Column(Integer, primary_key=True, index=True)
txn_no = Column(String(50), unique=True, index=True, nullable=False)
txn_type = Column(String(20), nullable=False, index=True)
partner_type = Column(String(20), nullable=False, index=True)
partner_id = Column(Integer, nullable=False, index=True)
amount = Column(Numeric(12, 2), nullable=False)
txn_date = Column(DateTime, default=func.now(), index=True)
method = Column(String(30), default="bank")
account_name = Column(String(100), nullable=True)
status = Column(String(20), default="confirmed", index=True)
remark = Column(Text, nullable=True)
operator_id = Column(Integer, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime, default=func.now(), index=True)
allocations = relationship("FinanceAllocation", back_populates="transaction", cascade="all, delete-orphan")
def __repr__(self):
return f"<FinanceTransaction(txn_no='{self.txn_no}', txn_type='{self.txn_type}', amount={self.amount})>"
class FinanceAllocation(Base):
__tablename__ = "finance_allocations"
id = Column(Integer, primary_key=True, index=True)
transaction_id = Column(Integer, ForeignKey("finance_transactions.id"), nullable=False, index=True)
order_type = Column(String(20), nullable=False, index=True)
order_id = Column(Integer, nullable=False, index=True)
allocated_amount = Column(Numeric(12, 2), nullable=False)
created_at = Column(DateTime, default=func.now(), index=True)
transaction = relationship("FinanceTransaction", back_populates="allocations")
def __repr__(self):
return f"<FinanceAllocation(transaction_id={self.transaction_id}, order_type='{self.order_type}', amount={self.allocated_amount})>"
class AnalysisMetrics(Base):
"""分析指标表"""
__tablename__ = "analysis_metrics"
id = Column(Integer, primary_key=True, index=True)
stp_file_id = Column(Integer, ForeignKey("stp_files.id"), nullable=False, index=True)
# 质量指标
volume_utilization = Column(Float, default=0) # 体积利用率
topology_complexity = Column(Float, default=0) # 拓扑复杂度
wall_uniformity = Column(Float, default=0) # 壁厚均匀性
# 分析摘要
analysis_summary = Column(Text, nullable=True)
# FreeCAD 验证结果
verification_status = Column(String(20), nullable=True) # passed, failed, pending, error
verification_volume_diff = Column(Float, nullable=True) # 体积差异百分比
verification_area_diff = Column(Float, nullable=True) # 表面积差异百分比
verification_details = Column(JSON, nullable=True) # 完整验证结果
# 时间戳
created_at = Column(DateTime, default=func.now())
# 关联关系
stp_file = relationship("STPFile", back_populates="analysis_metrics")
def __repr__(self):
return f"<AnalysisMetrics(stp_file_id={self.stp_file_id}, volume_utilization={self.volume_utilization})>"
class SalesOrderItem(Base):
"""销售订单明细表"""
__tablename__ = "sales_order_items"
id = Column(Integer, primary_key=True, index=True)
order_id = Column(Integer, ForeignKey("sales_orders.id"), nullable=False)
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
quantity = Column(Integer, nullable=False)
delivered_quantity = Column(Integer, default=0)
unit_price = Column(Numeric(12, 2), nullable=False)
amount = Column(Numeric(12, 2), nullable=False)
remark = Column(Text, nullable=True)
order = relationship("SalesOrder", back_populates="items")
def __repr__(self):
return f"<SalesOrderItem(order_id={self.order_id}, product_id={self.product_id})>"
+334
View File
@@ -0,0 +1,334 @@
"""
LLM 增强分析服务
提供两个核心能力:
1. generate_design_report — 将分析 JSON 转换为结构化评审报告
2. recommend_parting_direction — 基于几何 + 制造约束推荐最优分型方向
适配层:OpenAI 兼容 API(支持 OpenAI / DeepSeek / vLLM / Ollama 等)
未配置 LLM 时静默降级,不影响主流程。
"""
import json
import re
from typing import Optional, Dict, Any, List
import httpx
from config.settings import settings
from utils.logger import get_logger
logger = get_logger(__name__)
_DESIGN_REPORT_SYSTEM = """你是一位资深注塑模具设计工程师,拥有 20 年模具 DFM 评审经验。
请根据提供的模具分析数据,生成一份专业的模具设计评审报告。
要求:
1. 使用中文
2. 按 "问题摘要 → 关键风险 → 分模方案推荐 → 制造可行性 → 修改建议" 结构组织
3. 技术术语准确(如:锁模力、投影面积、分型面、滑块、斜顶、拔模角、缩痕、熔接痕)
4. 每个建议标注优先级(高/中/低)和预计工时
5. 报告末尾给出一个总体评分(1-10分)
6. 如果数据不足以判断某项,明确标注"数据不足,需人工确认"
直接输出 Markdown 格式报告,不要输出 JSON。"""
_DESIGN_REPORT_USER = """请根据以下模具分析数据生成评审报告:
## 产品信息
- 文件:{filename}
- 材料:{material}
- 体积:{volume}
- 表面积:{surface_area}
- 边界框:{bbox}
## 检测特征
{features}
## 质量指标
{quality_metrics}
## 分模方案
{schemes}
## 制造参数
- 推荐模具材料:{mold_material}
- 推荐模具硬度:{mold_hardness}
- 预估锁模力:{clamping_force}
- 模具尺寸(长×宽×高):{mold_size}
- 预估成型周期:{cycle_time}
- 拔模角:{draft_angle}
- 收缩率:{shrinkage_rate}
## 原始设计建议
{recommendations}"""
_PARTING_SYSTEM = """你是一位注塑模具分模专家。
根据产品几何特征和多个候选分模方向的评分数据,推荐最优分模方向。
输出要求:严格输出 JSON,不要输出其他内容。
JSON 格式:
{
"recommended_axis": "Z",
"confidence": 0.85,
"reasoning": "详细的中文推理过程...",
"risk_notes": ["风险1", "风险2"],
"rankings": [{"axis":"Z","rank":1,"score":92,"note":"..."},{"axis":"X","rank":2,"score":78,"note":"..."}]
}"""
_PARTING_USER = """请评估以下候选分模方向并推荐最优方案:
产品几何:
- 边界框 (mm):{bbox}
- 面法向分布:{normal_stats}
- 惯性矩:{inertia}
约束条件:
- 材料:{material}
- 型腔数:{cavity_count}
- 最大锁模力 (吨):{max_clamping_force}
- 泡沫材料:{is_foam}
候选方案:
{schemes}
请综合评估制造可行性、成本和风险,给出推荐。"""
class LLMService:
"""LLM 增强分析服务(单例)"""
def __init__(self):
self._enabled = settings.LLM_ENABLED
self._api_url = settings.LLM_API_URL.rstrip("/")
self._api_key = settings.LLM_API_KEY
self._model = settings.LLM_MODEL
self._timeout = settings.LLM_TIMEOUT
self._max_tokens = settings.LLM_MAX_TOKENS
if self._enabled:
logger.info(
"LLM 增强分析已启用: model=%s endpoint=%s",
self._model, self._api_url,
)
else:
logger.info("LLM 增强分析未启用(设置 LLM_ENABLED=true 启用)")
async def generate_design_report(
self,
analysis_result: Dict[str, Any],
detailed_cavity_json: Optional[Dict[str, Any]] = None,
) -> Optional[str]:
"""生成模具设计评审报告 (Markdown)"""
if not self._enabled:
return None
try:
prompt = self._build_design_report_prompt(analysis_result, detailed_cavity_json)
response = await self._chat(
system=_DESIGN_REPORT_SYSTEM,
user=prompt,
max_tokens=self._max_tokens,
)
if response:
logger.info("LLM 设计报告生成成功 (%d 字符)", len(response))
return response
except Exception as e:
logger.warning("LLM 设计报告生成失败(不影响主流程): %s", e)
return None
async def recommend_parting_direction(
self,
geometry_data: Dict[str, Any],
candidate_schemes: List[Dict[str, Any]],
material: Dict[str, Any],
cavity_count: int = 1,
) -> Optional[Dict[str, Any]]:
"""推荐最优分型方向"""
if not self._enabled:
return None
try:
prompt = self._build_parting_prompt(
geometry_data, candidate_schemes, material, cavity_count,
)
response = await self._chat(
system=_PARTING_SYSTEM,
user=prompt,
max_tokens=min(self._max_tokens, 1200),
expect_json=True,
)
if response:
result = self._parse_json_response(response)
if result:
logger.info(
"LLM 分型推荐: %s (置信度 %.2f)",
result.get("recommended_axis", "?"),
result.get("confidence", 0),
)
return result
return None
except Exception as e:
logger.warning("LLM 分型推荐失败(不影响主流程): %s", e)
return None
def _build_design_report_prompt(
self,
analysis_result: Dict[str, Any],
detailed_cavity_json: Optional[Dict[str, Any]],
) -> str:
detected_features = analysis_result.get("detected_features", [])
quality_metrics = analysis_result.get("quality_metrics", {})
recommendations = analysis_result.get("design_recommendations", [])
feature_text = json.dumps(detected_features, ensure_ascii=False, indent=2)
if len(feature_text) > 4000:
feature_text = feature_text[:4000] + "\n... (已截断)"
schemes_text = ""
if detailed_cavity_json:
schemes = detailed_cavity_json.get("candidate_schemes", [])
if schemes:
schemes_text = json.dumps(
[
{
"scheme_id": s.get("scheme_id"),
"rank": s.get("rank"),
"title": s.get("title"),
"score": s.get("score"),
"confidence_score": s.get("confidence_score"),
"summary": s.get("summary"),
"parting_axis": s.get("parting", {}).get("axis"),
"mold_structure_type": s.get("mold_structure_type"),
"dfm_violations": s.get("dfm_violations", []),
}
for s in schemes
],
ensure_ascii=False,
indent=2,
)
best_scheme = (
detailed_cavity_json.get("candidate_schemes", [{}])[0]
if detailed_cavity_json
else {}
)
cavity_data = best_scheme.get("cavity_data", {}) if isinstance(best_scheme, dict) else {}
mfg_info = cavity_data.get("manufacturing_info", {})
metadata = cavity_data.get("metadata", {})
return _DESIGN_REPORT_USER.format(
filename=metadata.get("file_name", "unknown.stp"),
material=metadata.get("selected_material", "ABS"),
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=feature_text or "无特征检测数据",
quality_metrics=json.dumps(quality_metrics, ensure_ascii=False, indent=2),
schemes=schemes_text or "无分模方案数据",
mold_material=mfg_info.get("mold_material", "自动选择"),
mold_hardness=mfg_info.get("mold_hardness", "自动选择"),
clamping_force=mfg_info.get("estimated_clamping_force", "自动计算"),
mold_size=json.dumps(mfg_info.get("estimated_mold_size", {}), ensure_ascii=False),
cycle_time=mfg_info.get("estimated_cycle_time", "自动计算"),
draft_angle=f"{metadata.get('draft_angle', 2.0)}°",
shrinkage_rate=f"{metadata.get('shrinkage_rate', 0.0) * 100:.2f}%"
if isinstance(metadata.get("shrinkage_rate"), (int, float))
else "自动计算",
recommendations=json.dumps(recommendations, ensure_ascii=False, indent=2) if recommendations else "无",
)
def _build_parting_prompt(
self,
geometry_data: Dict[str, Any],
candidate_schemes: List[Dict[str, Any]],
material: Dict[str, Any],
cavity_count: int,
) -> str:
bbox = geometry_data.get("bounding_box", {})
axis_normal_stats = geometry_data.get("axis_normal_stats", {})
inertia = geometry_data.get("inertia_matrix", [])
inertia_diag = [
inertia[i][i] if i < len(inertia) and i < len(inertia[i]) else 0.0
for i in range(3)
]
schemes_text = json.dumps(
[
{
"axis": s.get("parting", {}).get("axis") or s.get("axis"),
"score": s.get("score"),
"confidence_score": s.get("confidence_score"),
"summary": s.get("summary"),
"mold_structure_type": s.get("mold_structure_type"),
"core_required": s.get("core_required"),
"dfm_violations": s.get("dfm_violations", []),
"undercut_regions_count": len(s.get("undercut_regions", [])),
"score_breakdown": s.get("score_breakdown", {}),
}
for s in candidate_schemes
],
ensure_ascii=False,
indent=2,
)
return _PARTING_USER.format(
bbox=json.dumps(bbox, ensure_ascii=False),
normal_stats=json.dumps(axis_normal_stats, ensure_ascii=False),
inertia=json.dumps(inertia_diag, ensure_ascii=False),
material=material.get("name", "ABS"),
cavity_count=cavity_count,
max_clamping_force="3000 吨(最大)",
is_foam="是" if material.get("is_foam") else "否",
schemes=schemes_text,
)
async def _chat(
self,
system: str,
user: str,
max_tokens: int = 2000,
expect_json: bool = False,
temperature: float = 0.3,
) -> Optional[str]:
url = f"{self._api_url}/chat/completions"
headers = {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
}
payload = {
"model": self._model,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"max_tokens": max_tokens,
"temperature": temperature,
}
if expect_json:
payload["response_format"] = {"type": "json_object"}
async with httpx.AsyncClient(timeout=self._timeout) as client:
resp = await client.post(url, json=payload, headers=headers)
resp.raise_for_status()
data = resp.json()
content = data["choices"][0]["message"]["content"]
return content.strip() if content else None
@staticmethod
def _parse_json_response(raw: str) -> Optional[Dict[str, Any]]:
try:
return json.loads(raw)
except json.JSONDecodeError:
match = re.search(r"\{[\s\S]*\}", raw)
if match:
try:
return json.loads(match.group())
except json.JSONDecodeError:
pass
logger.warning("LLM JSON 解析失败: %s...", raw[:200])
return None
llm_service = LLMService()
-505
View File
@@ -1,505 +0,0 @@
# services/processing_service.py
"""STP 文件处理流程编排器 — 协调解析、网格生成、型腔生成、保存、验证"""
import asyncio
import traceback
from datetime import datetime
from pathlib import Path
from typing import Optional, Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession
from core.stp_parser import STPParser
from core.geometry_analyzer import GeometryAnalyzer
from core.mold_generator import MoldCavityGenerator
from core.aluminum_foam_mold import AluminumFoamMoldGenerator
from core.mold_quality_inspector import AluminumFoamMoldQualityInspector
from core.mesh_generator import MeshGenerator
from core.multi_scheme_planner import MultiSchemeMoldPlanner
from services.storage_integration_rustfs import StorageIntegrationService
from services.redis_task_manager import redis_task_manager
from services.material_service import MaterialService
from services.calculation_service import CalculationService
from models.schemas import ProcessingStatus
from database.database import db_manager
from utils.html_generator import HTMLGenerator
from utils.logger import get_logger
logger = get_logger(__name__)
class ProcessingService:
"""核心处理流程编排 — 协调 STP 解析、网格、型腔、计算、保存、验证"""
def __init__(self):
self.stp_parser = STPParser()
self.geometry_analyzer = GeometryAnalyzer()
self.mold_generator = MoldCavityGenerator(shrinkage_rate=0.005)
self.aluminum_foam_generator = AluminumFoamMoldGenerator(shrinkage_rate=0.015, draft_angle=3.0)
self.mold_quality_inspector = AluminumFoamMoldQualityInspector()
self.mesh_generator = MeshGenerator(quality="medium")
self.html_generator = HTMLGenerator()
self.storage_service = StorageIntegrationService()
self.multi_scheme_planner = MultiSchemeMoldPlanner()
# ─── 对外入口 ───
async def process_file_with_storage(
self,
task_id: str,
file_path: str,
stp_file_id: int,
material: str = "ABS",
):
"""处理文件的后台任务 — 使用独立数据库会话"""
# 创建独立的数据库会话,避免请求范围会话关闭
async with db_manager.session() as db_session:
try:
logger.info(f"开始处理文件并生成模具型腔: {file_path}")
# 设置处理超时(5分钟)
timeout_seconds = 300
try:
await asyncio.wait_for(
self.process_file_core(
task_id, file_path, stp_file_id, db_session, material
),
timeout_seconds,
)
except asyncio.TimeoutError:
logger.error(f"处理超时: {task_id}")
raise Exception(f"处理超时,超过{timeout_seconds}秒未完成")
except Exception as e:
logger.error(f"模具型腔生成失败: {e}")
await self.storage_service.update_stp_file_status(db_session, stp_file_id, "failed")
await self.storage_service.update_task_status(
db_session, task_id, "failed", error_message=str(e)
)
# 安全更新 Redis 任务状态
task = await redis_task_manager.get_task(task_id)
if task:
await redis_task_manager.update_task(task_id, {
"status": ProcessingStatus.FAILED,
"error": str(e),
"completed_at": str(datetime.now()),
})
async def process_file_core(
self,
task_id: str,
file_path: str,
stp_file_id: int,
db_session: AsyncSession,
material: str = "ABS",
):
"""核心处理逻辑"""
try:
logger.info(f"开始处理文件并生成模具型腔: {file_path}")
# 1. 解析STP文件
await self.storage_service.update_task_status(
db_session, task_id, "processing", 20, "解析STP文件"
)
shape = self.stp_parser.load_step_file(Path(file_path))
geometry_data = self.stp_parser.analyze_geometry(shape)
# 2. 生成网格数据并持久化
await self.storage_service.update_task_status(
db_session, task_id, "processing", 30, "生成网格数据"
)
mesh_result = await self._step_generate_mesh(
shape, geometry_data, file_path, db_session, stp_file_id, task_id
)
# 3. 生成模具型腔
await self.storage_service.update_task_status(
db_session, task_id, "processing", 40, "生成模具型腔"
)
# 材料属性 — 通过 MaterialService 集中管理
requested_material = MaterialService.resolve_material(material)
selected_material = MaterialService.get_material(requested_material)
is_foam_material = MaterialService.is_foam_material(requested_material)
plan_result = await self._step_generate_cavity(
shape, selected_material, is_foam_material
)
# 4. 生成详细JSON数据 — 委托 CalculationService
await self.storage_service.update_task_status(
db_session, task_id, "processing", 60, "生成型腔详细数据"
)
detailed_cavity_json = CalculationService.build_plan_result(
geometry_data=geometry_data,
material=selected_material,
file_path=str(file_path),
plan_result=plan_result,
)
best_scheme = CalculationService.get_best_scheme(detailed_cavity_json)
best_cavity_data = best_scheme.get("cavity_data", {}) if best_scheme else {}
best_key_info = best_scheme.get("key_info", {}) if best_scheme else {}
if best_cavity_data.get("mold_cavities"):
cavity_geometry = best_cavity_data["mold_cavities"].get("cavity", {})
logger.info(
f"推荐方案型腔数据已合并: cavity {cavity_geometry.get('vertex_count', 0)} 顶点"
)
# 5. 生成关键信息
cavity_key_info = best_key_info
# 6. 保存几何数据到数据库
await self.storage_service.update_task_status(
db_session, task_id, "processing", 70, "保存几何数据"
)
await self.storage_service.save_geometry_data(
db_session,
stp_file_id,
geometry_data,
geometry_data.get("analysis_method", "mold_cavity"),
)
# 7. 生成HTML可视化
await self.storage_service.update_task_status(
db_session, task_id, "processing", 85, "生成可视化报告"
)
pointcloud_data = None
lod_data = None
if mesh_result:
pointcloud_data = {
"points": mesh_result.get("points", []),
"normals": mesh_result.get("normals", []),
"vertices": mesh_result.get("vertices", []),
"faces": mesh_result.get("faces", []),
"point_count": mesh_result.get("point_count", 0),
"vertex_count": mesh_result.get("vertex_count", 0),
"face_count": mesh_result.get("face_count", 0),
}
# 生成多级LOD数据(用于前端按距离切换精度)
try:
lod_result = self.mesh_generator.generate_multi_lod_mesh(shape)
if lod_result and lod_result.get("lods"):
lod_data = lod_result
logger.info(f"LOD数据生成成功: {len(lod_result['lods'])} 级 (面数: {[lod_result['lods'][k]['face_count'] for k in sorted(lod_result['lods'].keys())]})")
except Exception as lod_err:
logger.warning(f"LOD数据生成失败,使用单级精度: {lod_err}")
detailed_cavity_json = await self._attach_scheme_previews(
detailed_cavity_json=detailed_cavity_json,
geometry_data=geometry_data,
stp_filename=Path(file_path).name,
pointcloud_data=pointcloud_data,
lod_data=lod_data,
)
best_scheme = CalculationService.get_best_scheme(detailed_cavity_json)
best_cavity_data = best_scheme.get("cavity_data", {}) if best_scheme else best_cavity_data
best_key_info = best_scheme.get("key_info", {}) if best_scheme else best_key_info
# 8. 保存模具型腔数据(包含方案级预览链接)
await self.storage_service.save_mold_cavity_data(
db_session, stp_file_id, detailed_cavity_json
)
html_file_path = self.html_generator.generate_and_save_visualization(
geometry_data,
Path(file_path).name,
cavity_data=best_cavity_data,
pointcloud_data=pointcloud_data,
lod_data=lod_data,
)
await self.storage_service.save_html_file(
db_session,
stp_file_id,
Path(html_file_path).name,
html_file_path,
)
# 9. 分析模具设计
analysis_result = self.geometry_analyzer.analyze_mold_design(geometry_data)
if analysis_result:
await self.storage_service.save_features_and_recommendations(
db_session,
stp_file_id,
analysis_result.get("detected_features", []),
analysis_result.get("design_recommendations", []),
)
await self._save_analysis_metrics(db_session, stp_file_id, analysis_result)
# 9.6 更新STP文件的分析摘要字段
await self.storage_service.update_stp_file_analysis_summary(
db_session,
stp_file_id,
volume=geometry_data.get("volume", 0),
surface_area=geometry_data.get("surface_area", 0),
product_weight=CalculationService.calculate_product_weight(
geometry_data.get("volume", 0), selected_material["density"]
),
)
# 9.7 FreeCAD 几何验证
verification_result = await self._step_verify(
file_path, db_session, task_id, stp_file_id, analysis_result
)
# 10. 完成处理
await self.storage_service.update_stp_file_status(db_session, stp_file_id, "completed")
await self.storage_service.update_task_status(
db_session, task_id, "completed", 100, "模具型腔生成完成"
)
# 更新任务缓存状态
await redis_task_manager.update_task(task_id, {
"geometry_data": geometry_data,
"analysis_result": analysis_result,
"plan_result": detailed_cavity_json,
"candidate_schemes": detailed_cavity_json.get("candidate_schemes", []),
"best_scheme_id": detailed_cavity_json.get("best_scheme_id"),
"cavity_data": best_cavity_data,
"key_info": best_key_info,
"html_file": best_scheme.get("html_file", f"/html/{Path(html_file_path).name}") if best_scheme else f"/html/{Path(html_file_path).name}",
"verification": verification_result,
"status": ProcessingStatus.COMPLETED,
"completed_at": str(datetime.now()),
})
logger.info(f"模具型腔生成完成: {task_id}")
logger.info(f"key_info metadata: {detailed_cavity_json.get('metadata', {})}")
logger.info(f"key_info manufacturing_info: {detailed_cavity_json.get('manufacturing_info', {})}")
logger.info(f"key_info geometric_characteristics: {detailed_cavity_json.get('mold_cavities', {}).get('cavity_key_info', {}).get('geometric_characteristics', {})}")
except Exception as e:
logger.error(f"模具型腔生成失败: {e}")
await self.storage_service.update_stp_file_status(db_session, stp_file_id, "failed")
await self.storage_service.update_task_status(
db_session, task_id, "failed", error_message=str(e)
)
task = await redis_task_manager.get_task(task_id)
if task:
await redis_task_manager.update_task(task_id, {
"status": ProcessingStatus.FAILED,
"error": str(e),
"completed_at": str(datetime.now()),
})
# ─── 内部步骤 ───
async def _step_generate_mesh(
self, shape, geometry_data: dict, file_path: str,
db_session: AsyncSession, stp_file_id: int, task_id: str,
) -> Optional[Dict[str, Any]]:
"""生成网格数据并持久化,失败不影响主流程"""
mesh_result = None
try:
mesh_result = self.mesh_generator.generate_mesh_from_shape(shape)
vertices = mesh_result.get("vertices", [])
faces = mesh_result.get("faces", [])
points = mesh_result.get("points", [])
normals = mesh_result.get("normals", [])
point_count = mesh_result.get("point_count", 0)
vertex_count = mesh_result.get("vertex_count", 0)
face_count = mesh_result.get("face_count", 0)
if vertices and faces:
bbox = geometry_data.get("bounding_box", {})
mesh_json = {
"metadata": {
"file_name": Path(file_path).name,
"generated_at": datetime.now().isoformat(),
"quality": "medium",
"vertex_count": vertex_count,
"face_count": face_count,
"point_count": point_count,
},
"mesh": {
"vertices": vertices,
"faces": faces,
},
"pointcloud": {
"points": points,
"normals": normals,
"count": point_count,
},
"bounding_box": bbox,
}
await self.storage_service.save_mesh_data(
db_session,
stp_file_id=stp_file_id,
mesh_json=mesh_json,
quality="medium",
)
await redis_task_manager.update_task(task_id, {
"mesh_summary": {
"vertex_count": vertex_count,
"face_count": face_count,
"point_count": point_count,
"quality": "medium",
}
})
except Exception as mesh_err:
logger.warning(f"网格生成或保存失败,不影响主流程: {mesh_err}")
return mesh_result
async def _step_generate_cavity(
self, shape, selected_material: dict, is_foam_material: bool,
) -> Optional[Dict[str, Any]]:
"""生成多方案分模结果"""
plan_result = None
try:
if shape:
plan_result = self.multi_scheme_planner.generate_plan(
shape=shape,
material=selected_material,
is_foam_material=is_foam_material,
)
logger.info(
f"多方案分模完成: 生成 {len(plan_result.get('candidate_schemes', []))} 套方案"
)
except Exception as cavity_err:
logger.warning(f"多方案分模失败,使用简化数据: {cavity_err}")
traceback.print_exc()
plan_result = None
return plan_result
async def _step_verify(
self, file_path: str, db_session: AsyncSession,
task_id: str, stp_file_id: int, analysis_result: Optional[dict],
) -> Optional[Dict[str, Any]]:
"""FreeCAD 几何验证(可通过配置禁用)"""
from config.settings import settings
if not settings.ENABLE_FREECAD_VERIFICATION:
logger.info("FreeCAD验证已禁用(设置 ENABLE_FREECAD_VERIFICATION=true 启用)")
return {"status": "disabled", "reason": "FreeCAD验证已禁用"}
await self.storage_service.update_task_status(
db_session, task_id, "processing", 90, "FreeCAD几何验证"
)
try:
from services.verification_service import GeometryVerificationService
verification_svc = GeometryVerificationService(timeout=settings.FREECAD_VERIFICATION_TIMEOUT)
verification_result = await verification_svc.verify_stp_file(file_path)
if verification_result and analysis_result:
await self._save_verification_metrics(db_session, stp_file_id, verification_result)
logger.info(f"FreeCAD验证完成: {verification_result.get('status', 'unknown') if verification_result else 'failed'}")
return verification_result
except Exception as ve:
logger.warning(f"FreeCAD验证失败(不影响主流程): {ve}")
return {"status": "error", "error": str(ve)}
async def _attach_scheme_previews(
self,
detailed_cavity_json: Dict[str, Any],
geometry_data: Dict[str, Any],
stp_filename: str,
pointcloud_data: Optional[Dict[str, Any]] = None,
lod_data: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""为每个候选分模方案生成独立HTML预览链接。"""
candidate_schemes = detailed_cavity_json.get("candidate_schemes", [])
if not candidate_schemes:
return detailed_cavity_json
for scheme in candidate_schemes:
cavity_data = scheme.get("cavity_data")
if not cavity_data:
continue
suffix = scheme.get("scheme_id")
html_path = self.html_generator.generate_and_save_visualization(
geometry_data,
stp_filename,
cavity_data=cavity_data,
pointcloud_data=pointcloud_data,
suffix=suffix,
lod_data=lod_data,
)
scheme["html_file"] = f"/html/{Path(html_path).name}"
best_scheme = CalculationService.get_best_scheme(detailed_cavity_json)
if best_scheme:
detailed_cavity_json["html_file"] = best_scheme.get("html_file")
return detailed_cavity_json
# ─── 指标持久化 ───
async def _save_analysis_metrics(self, session: AsyncSession, stp_file_id: int, analysis_result: dict):
"""保存分析指标到数据库"""
from models.database import AnalysisMetrics
quality_metrics = analysis_result.get("quality_metrics", {})
analysis_summary = analysis_result.get("analysis_summary", "")
metrics = AnalysisMetrics(
stp_file_id=stp_file_id,
volume_utilization=quality_metrics.get("volume_utilization", 0),
topology_complexity=quality_metrics.get("topology_complexity", 0),
wall_uniformity=quality_metrics.get("wall_uniformity", 0),
analysis_summary=analysis_summary,
)
session.add(metrics)
await session.commit()
logger.info(f"分析指标保存成功: {metrics.id}")
async def _save_verification_metrics(self, session: AsyncSession, stp_file_id: int, verification_result: dict):
"""保存验证指标到数据库"""
from models.database import AnalysisMetrics
from sqlalchemy import select
result = await session.execute(
select(AnalysisMetrics).where(AnalysisMetrics.stp_file_id == stp_file_id)
)
metrics = result.scalar_one_or_none()
comparison = verification_result.get("comparison", {})
volume_comparison = comparison.get("volume", {})
area_comparison = comparison.get("surface_area", {})
if metrics:
metrics.verification_status = verification_result.get("status", "unknown")
metrics.verification_volume_diff = volume_comparison.get("difference_percent", 0)
metrics.verification_area_diff = area_comparison.get("difference_percent", 0)
metrics.verification_details = verification_result
else:
metrics = AnalysisMetrics(
stp_file_id=stp_file_id,
verification_status=verification_result.get("status", "unknown"),
verification_volume_diff=volume_comparison.get("difference_percent", 0),
verification_area_diff=area_comparison.get("difference_percent", 0),
verification_details=verification_result,
)
session.add(metrics)
await session.commit()
logger.info(f"验证指标保存成功: stp_file_id={stp_file_id}")
# 模块级单例,供路由层直接使用
processing_service = ProcessingService()
-831
View File
@@ -1,831 +0,0 @@
# utils/html_generator.py
from pathlib import Path
from typing import Dict, Any, Optional
from datetime import datetime
from utils.logger import get_logger
logger = get_logger(__name__)
try:
import orjson
def _json_dumps(obj: Any) -> bytes:
return orjson.dumps(obj, option=orjson.OPT_SERIALIZE_NUMPY | orjson.OPT_NON_STR_KEYS)
def _json_dumps_str(obj: Any) -> str:
return orjson.dumps(obj, option=orjson.OPT_SERIALIZE_NUMPY | orjson.OPT_NON_STR_KEYS).decode("utf-8")
_JSON_FAST = True
except ImportError:
import json
def _json_dumps(obj: Any) -> bytes:
return json.dumps(obj, ensure_ascii=False).encode("utf-8")
def _json_dumps_str(obj: Any) -> str:
return json.dumps(obj, ensure_ascii=False)
_JSON_FAST = False
class HTMLGenerator:
"""HTML文件生成器 — 数据分离架构,Three.js 0.170 + PBR渲染"""
def __init__(self, output_dir: str = "./html_output"):
self.output_dir = Path(output_dir)
self.output_dir.mkdir(exist_ok=True)
def generate_3d_viewer_html(self, stp_filename: str, data_filename: str) -> str:
"""生成3D可视化HTML页面 — 通过fetch异步加载companion JSON数据"""
cavity_html = self._build_cavity_info_panel_template()
html_content = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>3D模具几何可视化 - {stp_filename}</title>
<script type="importmap">
{{
"imports": {{
"three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
}}
}}
</script>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ overflow: hidden; font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif; background: #f0f2f5; }}
#container {{ position: relative; width: 100vw; height: 100vh; }}
#canvas {{ display: block; }}
#loading-overlay {{
position: absolute; inset: 0; display: flex; flex-direction: column;
align-items: center; justify-content: center; background: rgba(240,242,245,0.95);
z-index: 100; transition: opacity 0.4s;
}}
#loading-overlay.hidden {{ opacity: 0; pointer-events: none; }}
.spinner {{
width: 48px; height: 48px; border: 3px solid rgba(0,0,0,0.1);
border-top-color: #4CAF50; border-radius: 50%; animation: spin 0.8s linear infinite;
}}
@keyframes spin {{ to {{ transform: rotate(360deg); }} }}
.loading-text {{ color: #666; margin-top: 16px; font-size: 14px; }}
#info-panel {{
position: absolute; top: 10px; left: 10px; background: rgba(255,255,255,0.92);
color: #333; padding: 12px 16px; border-radius: 10px; font-size: 13px;
max-width: 340px; backdrop-filter: blur(10px); border: 1px solid rgba(0,0,0,0.08);
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
}}
#info-panel h3 {{ margin: 0 0 6px 0; font-size: 14px; color: #2E7D32; }}
#info-panel .info-row {{ display: flex; justify-content: space-between; padding: 2px 0; }}
#info-panel .info-label {{ color: #888; }}
#info-panel .info-value {{ color: #111; font-weight: 500; }}
#cavity-info-panel {{
position: absolute; top: 10px; right: 10px; background: rgba(255,255,255,0.92);
color: #333; padding: 15px; border-radius: 10px; font-size: 12px;
max-width: 310px; backdrop-filter: blur(10px); border: 1px solid rgba(0,0,0,0.08);
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
display: none;
}}
#cavity-info-panel h3 {{ margin: 0 0 10px 0; font-size: 14px; color: #E65100; }}
#cavity-info-panel .metric {{ display: flex; justify-content: space-between; padding: 3px 0; }}
#cavity-info-panel .metric-label {{ color: #888; }}
#cavity-info-panel .metric-value {{ color: #111; font-weight: 500; }}
#toolbar {{
position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%);
display: flex; gap: 6px; background: rgba(255,255,255,0.92); padding: 8px 12px;
border-radius: 24px; backdrop-filter: blur(10px); border: 1px solid rgba(0,0,0,0.08);
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
flex-wrap: wrap; justify-content: center;
}}
#toolbar button {{
background: rgba(0,0,0,0.04); color: #555; border: 1px solid rgba(0,0,0,0.1);
padding: 6px 14px; border-radius: 18px; cursor: pointer; font-size: 12px;
transition: all 0.2s; white-space: nowrap;
}}
#toolbar button:hover {{ background: rgba(0,0,0,0.1); color: #222; }}
#toolbar button.active {{ background: rgba(76,175,80,0.18); border-color: #4CAF50; color: #2E7D32; }}
#toolbar button.accent {{
background: rgba(255,87,34,0.15); border-color: #FF5722; color: #D84315;
font-weight: bold;
}}
#toolbar button.accent:hover {{ background: rgba(255,87,34,0.28); }}
#view-presets {{
position: absolute; bottom: 75px; left: 50%; transform: translateX(-50%);
display: flex; gap: 4px; background: rgba(255,255,255,0.88); padding: 6px 8px;
border-radius: 20px; backdrop-filter: blur(8px); border: 1px solid rgba(0,0,0,0.06);
box-shadow: 0 1px 8px rgba(0,0,0,0.06);
}}
#view-presets button {{
background: rgba(0,0,0,0.03); color: #777; border: none;
padding: 5px 10px; border-radius: 14px; cursor: pointer; font-size: 11px;
transition: all 0.2s;
}}
#view-presets button:hover {{ background: rgba(0,0,0,0.1); color: #222; }}
@media (max-width: 768px) {{
#info-panel {{ max-width: 240px; font-size: 11px; padding: 8px 12px; }}
#cavity-info-panel {{ max-width: 220px; font-size: 10px; padding: 10px; }}
#toolbar {{ gap: 3px; padding: 6px 8px; }}
#toolbar button {{ padding: 5px 10px; font-size: 10px; }}
#view-presets {{ bottom: 68px; }}
}}
</style>
</head>
<body>
<div id="container">
<canvas id="canvas"></canvas>
<div id="loading-overlay">
<div class="spinner"></div>
<div class="loading-text" id="loading-status">加载几何数据...</div>
</div>
<div id="info-panel">
<h3>📐 {stp_filename}</h3>
<div class="info-row"><span class="info-label">顶点</span><span class="info-value" id="info-verts">-</span></div>
<div class="info-row"><span class="info-label">三角面</span><span class="info-value" id="info-faces">-</span></div>
<div class="info-row"><span class="info-label">点云</span><span class="info-value" id="info-points">-</span></div>
<div class="info-row"><span class="info-label">体积</span><span class="info-value" id="info-vol">-</span></div>
</div>
{cavity_html}
<div id="view-presets">
<button onclick="setView('front')" title="前视">前</button>
<button onclick="setView('back')" title="后视">后</button>
<button onclick="setView('left')" title="左视">左</button>
<button onclick="setView('right')" title="右视">右</button>
<button onclick="setView('top')" title="俯视">俯</button>
<button onclick="setView('bottom')" title="仰视">仰</button>
<button onclick="setView('iso')" title="等轴测" style="font-weight:bold;color:#FF9800;">3D</button>
</div>
<div id="toolbar">
<button id="btn-product" class="active" onclick="toggleProduct()">产品</button>
<button id="btn-mold" class="active" onclick="toggleMold()">模具</button>
<button id="btn-parting" class="active" onclick="toggleParting()">分型面</button>
<button id="btn-pointcloud" onclick="togglePointcloud()">点云</button>
<button onclick="toggleWireframe()">线框</button>
<button onclick="resetView()">重置</button>
<button id="splitBtn" class="accent" onclick="splitMold()">分模拆分</button>
</div>
</div>
<script type="module">
import * as THREE from 'three';
import {{ OrbitControls }} from 'three/addons/controls/OrbitControls.js';
const DATA_URL = '{data_filename}';
let productMesh, cavityMesh, coreMesh, partingMesh, pointcloudMesh;
let productVisible = true, moldVisible = true, partingVisible = true, pointcloudVisible = false;
let isSplit = false, splitAnimId = null;
let sceneBox = null;
let cavityDataGlobal = null;
const renderer = new THREE.WebGLRenderer({{ canvas: document.getElementById('canvas'), antialias: true, alpha: true }});
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.2;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xf0f2f5);
scene.fog = new THREE.Fog(0xf0f2f5, 500, 3000);
const camera = new THREE.PerspectiveCamera(55, window.innerWidth / window.innerHeight, 0.1, 10000);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.12;
controls.minDistance = 1;
controls.maxDistance = 5000;
controls.target.set(0, 0, 0);
function setupLighting() {{
const ambient = new THREE.AmbientLight(0xccccdd, 4);
scene.add(ambient);
const keyLight = new THREE.DirectionalLight(0xffffff, 7);
keyLight.position.set(1, 1.2, 0.8);
keyLight.castShadow = true;
keyLight.shadow.mapSize.width = 2048;
keyLight.shadow.mapSize.height = 2048;
keyLight.shadow.camera.near = 0.5;
keyLight.shadow.camera.far = 500;
keyLight.shadow.bias = -0.0001;
scene.add(keyLight);
const fillLight = new THREE.DirectionalLight(0xccddff, 3);
fillLight.position.set(-0.6, 0.3, -0.4);
scene.add(fillLight);
const rimLight = new THREE.DirectionalLight(0xffffff, 4);
rimLight.position.set(0, -0.3, -1);
scene.add(rimLight);
const bottomLight = new THREE.DirectionalLight(0x8899cc, 1.5);
bottomLight.position.set(0, -1, 0.2);
scene.add(bottomLight);
const pmremGenerator = new THREE.PMREMGenerator(renderer);
pmremGenerator.compileEquirectangularShader();
const envScene = new THREE.Scene();
envScene.background = new THREE.Color(0xddeeff);
const envMap = pmremGenerator.fromScene(envScene).texture;
scene.environment = envMap;
scene.background = new THREE.Color(0xf0f2f5);
}}
setupLighting();
const axesHelper = new THREE.AxesHelper(50);
scene.add(axesHelper);
const gridHelper = new THREE.GridHelper(400, 40, 0xccccdd, 0xe8e8f0);
scene.add(gridHelper);
function toFlatArray(data) {{
if (!Array.isArray(data)) return [];
if (data.length === 0) return [];
return Array.isArray(data[0]) ? data.flat(Infinity) : data;
}}
function normalizePositions(rawPositions, center) {{
const flat = toFlatArray(rawPositions);
if (!flat.length) return [];
const cx = Number(center[0] || 0), cy = Number(center[1] || 0), cz = Number(center[2] || 0);
const normalized = [];
for (let i = 0; i + 2 < flat.length; i += 3) {{
const x = Number(flat[i]), y = Number(flat[i + 1]), z = Number(flat[i + 2]);
if (Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z)) {{
normalized.push(x - cx, y - cy, z - cz);
}}
}}
return normalized;
}}
function computeBounds(rawPositionsList) {{
let minX = Infinity, minY = Infinity, minZ = Infinity;
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
let hasPoint = false;
for (const raw of rawPositionsList) {{
const flat = toFlatArray(raw);
for (let i = 0; i + 2 < flat.length; i += 3) {{
const x = Number(flat[i]), y = Number(flat[i + 1]), z = Number(flat[i + 2]);
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) continue;
hasPoint = true;
minX = Math.min(minX, x); minY = Math.min(minY, y); minZ = Math.min(minZ, z);
maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); maxZ = Math.max(maxZ, z);
}}
}}
if (!hasPoint) return null;
return {{
center: [(minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2],
dimensions: [Math.max(maxX - minX, 1), Math.max(maxY - minY, 1), Math.max(maxZ - minZ, 1)],
}};
}}
function isValidIndexedGeometry(positions, indices) {{
if (!positions || !indices) return false;
if (positions.length < 9 || indices.length < 3) return false;
if (positions.length % 3 !== 0 || indices.length % 3 !== 0) return false;
const vertexCount = positions.length / 3;
for (let i = 0; i < indices.length; i++) {{
const idx = Number(indices[i]);
if (!Number.isFinite(idx) || idx < 0 || idx >= vertexCount) return false;
}}
return true;
}}
function createPBRMaterial(colorHex, opts = {{}}) {{
return new THREE.MeshStandardMaterial({{
color: new THREE.Color(colorHex),
metalness: opts.metalness ?? 0.05,
roughness: opts.roughness ?? 0.35,
transparent: true,
opacity: opts.opacity ?? 0.55,
side: THREE.DoubleSide,
depthWrite: opts.depthWrite ?? true,
}});
}}
function registerInitialPose(mesh) {{
if (!mesh) return;
mesh.userData.initialPosition = mesh.position.clone();
mesh.userData.initialVisible = mesh.visible;
}}
function fitCameraToScene() {{
const objects = [productMesh, cavityMesh, coreMesh, partingMesh, pointcloudMesh].filter(Boolean);
if (!objects.length) return;
sceneBox = new THREE.Box3();
objects.forEach(obj => sceneBox.expandByObject(obj));
if (sceneBox.isEmpty()) return;
const center = new THREE.Vector3();
const size = new THREE.Vector3();
sceneBox.getCenter(center);
sceneBox.getSize(size);
const maxDim = Math.max(size.x, size.y, size.z) || 100;
const distance = Math.max(maxDim * 1.6, 30);
camera.near = Math.max(maxDim / 2000, 0.01);
camera.far = Math.max(maxDim * 200, 10000);
camera.updateProjectionMatrix();
camera.position.set(center.x + distance * 0.7, center.y + distance * 0.7, center.z + distance * 0.8);
controls.target.copy(center);
controls.minDistance = Math.max(maxDim * 0.03, 0.5);
controls.maxDistance = Math.max(maxDim * 30, 5000);
controls.update();
}}
function setView(direction) {{
if (!sceneBox) return;
const center = new THREE.Vector3();
const size = new THREE.Vector3();
sceneBox.getCenter(center);
sceneBox.getSize(size);
const dist = Math.max(size.x, size.y, size.z) * 1.5;
const positions = {{
front: [0, 0, dist],
back: [0, 0, -dist],
left: [-dist, 0, 0],
right: [dist, 0, 0],
top: [0, dist, 0],
bottom: [0, -dist, 0],
iso: [dist * 0.7, dist * 0.7, dist * 0.8],
}};
const pos = positions[direction] || positions.iso;
camera.position.set(center.x + pos[0], center.y + pos[1], center.z + pos[2]);
controls.target.copy(center);
controls.update();
}}
window.setView = setView;
function buildScene(geometryData, cavityData, pointcloudData) {{
cavityDataGlobal = cavityData;
const bbox = geometryData.bounding_box || computeBounds([
pointcloudData?.points,
cavityData?.mold_cavities?.cavity?.vertices,
cavityData?.mold_cavities?.core?.vertices
]) || {{ center: [0, 0, 0], dimensions: [100, 100, 100] }};
const centerOffset = bbox.center || [0, 0, 0];
const width = (bbox.dimensions && bbox.dimensions[0]) || 100;
const height = (bbox.dimensions && bbox.dimensions[1]) || 100;
const depth = (bbox.dimensions && bbox.dimensions[2]) || 100;
const coreRequired = cavityData?.metadata?.core_required !== false;
const maxDim = Math.max(width, height, depth) || 100;
const lods = pointcloudData?.lods;
if (lods && lods["0"] && lods["0"].vertices && lods["0"].faces) {{
const lodGroup = new THREE.LOD();
const lodKeys = Object.keys(lods).sort((a, b) => Number(a) - Number(b));
for (const key of lodKeys) {{
const entry = lods[key];
if (!entry.vertices || !entry.faces || entry.vertices.length === 0 || entry.faces.length === 0) continue;
const productVerts = normalizePositions(entry.vertices, centerOffset);
const productFaces = toFlatArray(entry.faces);
if (productVerts.length < 9 || productFaces.length < 3) continue;
const lodGeo = new THREE.BufferGeometry();
lodGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(productVerts), 3));
lodGeo.setIndex(new THREE.BufferAttribute(new Uint32Array(productFaces), 1));
lodGeo.computeVertexNormals();
const lodMat = createPBRMaterial(0xFFFFFF, {{ metalness: 0.0, roughness: 0.20, opacity: 0.55 }});
const lodMesh = new THREE.Mesh(lodGeo, lodMat);
lodMesh.castShadow = true;
lodMesh.receiveShadow = true;
const dist = key === "0" ? 0 : key === "1" ? maxDim * 3 : maxDim * 8;
lodGroup.addLevel(lodMesh, dist);
}}
productMesh = lodGroup;
scene.add(productMesh);
registerInitialPose(productMesh);
}} else if (pointcloudData && pointcloudData.vertices && pointcloudData.faces && pointcloudData.vertices.length > 0 && pointcloudData.faces.length > 0) {{
const productVerts = normalizePositions(pointcloudData.vertices, centerOffset);
const productFaces = toFlatArray(pointcloudData.faces);
if (productVerts.length >= 9 && productFaces.length >= 3) {{
const productGeometry = new THREE.BufferGeometry();
productGeometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(productVerts), 3));
productGeometry.setIndex(new THREE.BufferAttribute(new Uint32Array(productFaces), 1));
productGeometry.computeVertexNormals();
const productMaterial = createPBRMaterial(0xFFFFFF, {{ metalness: 0.0, roughness: 0.20, opacity: 0.55 }});
productMesh = new THREE.Mesh(productGeometry, productMaterial);
productMesh.castShadow = true;
productMesh.receiveShadow = true;
scene.add(productMesh);
registerInitialPose(productMesh);
}}
}}
if (!productMesh && pointcloudData && pointcloudData.points && pointcloudData.points.length > 0) {{
const pointPositions = normalizePositions(pointcloudData.points, centerOffset);
if (pointPositions.length >= 3) {{
const ptGeometry = new THREE.BufferGeometry();
ptGeometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(pointPositions), 3));
if (pointcloudData.normals && pointcloudData.normals.length > 0) {{
const normals = new Float32Array(toFlatArray(pointcloudData.normals));
if (normals.length === pointPositions.length) {{
ptGeometry.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
}}
}}
const ptMaterial = new THREE.PointsMaterial({{
color: 0xFFFFFF, size: 0.5, sizeAttenuation: true,
transparent: true, opacity: 0.90, blending: THREE.NormalBlending,
depthWrite: false,
}});
pointcloudMesh = new THREE.Points(ptGeometry, ptMaterial);
pointcloudMesh.visible = pointcloudVisible;
scene.add(pointcloudMesh);
registerInitialPose(pointcloudMesh);
}}
}}
if (!productMesh && !pointcloudMesh) {{
const productGeometry = new THREE.BoxGeometry(width * 0.9, height * 0.9, depth * 0.9);
const productMaterial = createPBRMaterial(0xFFFFFF, {{ metalness: 0.0, roughness: 0.25, opacity: 0.65 }});
productMesh = new THREE.Mesh(productGeometry, productMaterial);
productMesh.position.set(0, 0, 0);
scene.add(productMesh);
registerInitialPose(productMesh);
}}
if (cavityData && cavityData.mold_cavities && cavityData.mold_cavities.cavity) {{
const cd = cavityData.mold_cavities.cavity;
if (cd.vertices && cd.faces && cd.vertices.length > 0 && cd.faces.length > 0) {{
const cavityGeometry = new THREE.BufferGeometry();
const positions = new Float32Array(normalizePositions(cd.vertices, centerOffset));
const indices = new Uint32Array(toFlatArray(cd.faces));
if (isValidIndexedGeometry(positions, indices)) {{
cavityGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
cavityGeometry.setIndex(new THREE.BufferAttribute(indices, 1));
cavityGeometry.computeVertexNormals();
const cavityMaterial = createPBRMaterial(0x4488cc, {{ metalness: 0.7, roughness: 0.3, opacity: 0.45, depthWrite: false }});
cavityMesh = new THREE.Mesh(cavityGeometry, cavityMaterial);
cavityMesh.renderOrder = 1;
scene.add(cavityMesh);
registerInitialPose(cavityMesh);
addWireframe(cavityMesh, cavityGeometry, 0x3388bb);
}}
}}
}}
if (!cavityMesh) createSimpleCavity(width, height, depth, centerOffset);
if (coreRequired && cavityData && cavityData.mold_cavities && cavityData.mold_cavities.core) {{
const cd = cavityData.mold_cavities.core;
if (cd.vertices && cd.faces && cd.vertices.length > 0 && cd.faces.length > 0) {{
const coreGeometry = new THREE.BufferGeometry();
const positions = new Float32Array(normalizePositions(cd.vertices, centerOffset));
const indices = new Uint32Array(toFlatArray(cd.faces));
if (isValidIndexedGeometry(positions, indices)) {{
coreGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
coreGeometry.setIndex(new THREE.BufferAttribute(indices, 1));
coreGeometry.computeVertexNormals();
const coreMaterial = createPBRMaterial(0xdd8822, {{ metalness: 0.7, roughness: 0.3, opacity: 0.45, depthWrite: false }});
coreMesh = new THREE.Mesh(coreGeometry, coreMaterial);
coreMesh.renderOrder = 1;
scene.add(coreMesh);
registerInitialPose(coreMesh);
addWireframe(coreMesh, coreGeometry, 0xcc6600);
}}
}}
}}
if (!coreMesh && coreRequired) createSimpleCore(width, height, depth, centerOffset);
const partingGeometry = new THREE.PlaneGeometry(width * 1.2, height * 1.2);
const partingMaterial = new THREE.MeshBasicMaterial({{
color: 0xF44336, transparent: true, opacity: 0.25, side: THREE.DoubleSide, depthWrite: false,
}});
partingMesh = new THREE.Mesh(partingGeometry, partingMaterial);
partingMesh.position.set(centerOffset[0], centerOffset[1], centerOffset[2]);
partingMesh.renderOrder = 2;
scene.add(partingMesh);
registerInitialPose(partingMesh);
if (productMesh) {{
if (productMesh.isLOD) {{
productMesh.traverse(child => {{
if (child.isMesh && child.geometry) {{
addWireframe(child, child.geometry, 0xCCCCCC);
}}
}});
}} else {{
addWireframe(productMesh, productMesh.geometry, 0xCCCCCC);
}}
}}
updateInfoPanel(pointcloudData, cavityData);
fitCameraToScene();
}}
function addWireframe(parent, geometry, colorHex) {{
const wf = new THREE.WireframeGeometry(geometry);
const line = new THREE.LineSegments(wf, new THREE.LineBasicMaterial({{
color: colorHex, transparent: true, opacity: 0.25, depthTest: true, depthWrite: false,
}}));
line.renderOrder = 3;
parent.add(line);
}}
function createSimpleCavity(width, height, depth, center) {{
const halfDepth = depth / 2;
const cGeo = new THREE.BoxGeometry(width * 1.2, height * 1.2, halfDepth + 10);
const cMat = createPBRMaterial(0x4488cc, {{ metalness: 0.6, roughness: 0.35, opacity: 0.35, depthWrite: false }});
cavityMesh = new THREE.Mesh(cGeo, cMat);
cavityMesh.position.set(center[0], center[1], center[2] + halfDepth / 2 + 5);
cavityMesh.renderOrder = 1;
scene.add(cavityMesh);
registerInitialPose(cavityMesh);
addWireframe(cavityMesh, cGeo, 0x3388bb);
}}
function createSimpleCore(width, height, depth, center) {{
const halfDepth = depth / 2;
const cGeo = new THREE.BoxGeometry(width * 1.2, height * 1.2, halfDepth + 10);
const cMat = createPBRMaterial(0xdd8822, {{ metalness: 0.6, roughness: 0.35, opacity: 0.35, depthWrite: false }});
coreMesh = new THREE.Mesh(cGeo, cMat);
coreMesh.position.set(center[0], center[1], center[2] - halfDepth / 2 - 5);
coreMesh.renderOrder = 1;
scene.add(coreMesh);
registerInitialPose(coreMesh);
addWireframe(coreMesh, cGeo, 0xcc6600);
}}
function updateInfoPanel(pointcloudData, cavityData) {{
const verts = pointcloudData?.vertex_count || cavityData?.mold_cavities?.cavity?.vertex_count || '-';
const faces = pointcloudData?.face_count || cavityData?.mold_cavities?.cavity?.face_count || '-';
const pts = pointcloudData?.point_count || '-';
const vol = cavityData?.mold_cavities?.cavity_key_info?.geometric_characteristics?.product_volume || '-';
document.getElementById('info-verts').textContent = typeof verts === 'number' ? verts.toLocaleString() : verts;
document.getElementById('info-faces').textContent = typeof faces === 'number' ? faces.toLocaleString() : faces;
document.getElementById('info-points').textContent = typeof pts === 'number' ? pts.toLocaleString() : pts;
document.getElementById('info-vol').textContent = vol;
const panel = document.getElementById('cavity-info-panel');
if (panel && cavityData) {{
panel.style.display = 'block';
const meta = cavityData.metadata || {{}};
const mfg = cavityData.manufacturing_info || {{}};
const geo = cavityData.mold_cavities?.cavity_key_info?.geometric_characteristics || {{}};
const setVal = (id, val) => {{ const el = document.getElementById(id); if (el) el.textContent = val || 'N/A'; }};
setVal('cp-shrink', meta.shrinkage_rate);
setVal('cp-draft', meta.draft_angle != null ? meta.draft_angle + '°' : null);
setVal('cp-parting', mfg.parting_line_length);
setVal('cp-vol', geo.product_volume);
setVal('cp-weight', geo.product_weight);
setVal('cp-wall', geo.wall_thickness_range);
setVal('cp-material', mfg.mold_material);
setVal('cp-hardness', mfg.mold_hardness);
setVal('cp-finish', mfg.surface_finish);
setVal('cp-cycle', mfg.estimated_cycle_time);
}}
}}
async function loadData() {{
const statusEl = document.getElementById('loading-status');
try {{
statusEl.textContent = '正在加载几何数据...';
const resp = await fetch(DATA_URL);
if (!resp.ok) throw new Error(`HTTP ${{resp.status}}`);
const data = await resp.json();
statusEl.textContent = '正在构建3D场景...';
await new Promise(r => setTimeout(r, 30));
buildScene(
data.geometry || {{}},
data.cavity || null,
data.pointcloud || null
);
statusEl.textContent = '完成';
document.getElementById('loading-overlay').classList.add('hidden');
}} catch (err) {{
console.error('数据加载失败:', err);
statusEl.textContent = '加载失败: ' + err.message;
statusEl.style.color = '#F44336';
}}
}}
function animate() {{
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}}
loadData().then(() => animate());
window.addEventListener('resize', () => {{
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}});
window.resetView = function() {{
if (splitAnimId) {{ cancelAnimationFrame(splitAnimId); splitAnimId = null; }}
isSplit = false;
const btn = document.getElementById('splitBtn');
if (btn) btn.textContent = '分模拆分';
[productMesh, cavityMesh, coreMesh, partingMesh, pointcloudMesh].forEach(mesh => {{
if (!mesh) return;
if (mesh.userData.initialPosition) mesh.position.copy(mesh.userData.initialPosition);
else mesh.position.set(0, 0, 0);
mesh.visible = mesh.userData.initialVisible !== false;
}});
if (partingMesh && partingMesh.material) {{ partingMesh.material.opacity = 0.25; partingMesh.visible = true; }}
productVisible = true; moldVisible = true; partingVisible = true;
document.getElementById('btn-product').classList.add('active');
document.getElementById('btn-mold').classList.add('active');
document.getElementById('btn-parting').classList.add('active');
fitCameraToScene();
}};
window.toggleWireframe = function() {{
scene.traverse(child => {{ if (child.isMesh) child.material.wireframe = !child.material.wireframe; }});
}};
window.toggleProduct = function() {{
if (!productMesh && !pointcloudMesh) return;
productVisible = !productVisible;
if (productMesh) productMesh.visible = productVisible;
document.getElementById('btn-product').classList.toggle('active', productVisible);
}};
window.toggleMold = function() {{
moldVisible = !moldVisible;
if (cavityMesh) cavityMesh.visible = moldVisible;
if (coreMesh) coreMesh.visible = moldVisible;
document.getElementById('btn-mold').classList.toggle('active', moldVisible);
}};
window.toggleParting = function() {{
partingVisible = !partingVisible;
if (partingMesh) partingMesh.visible = partingVisible;
document.getElementById('btn-parting').classList.toggle('active', partingVisible);
}};
window.togglePointcloud = function() {{
pointcloudVisible = !pointcloudVisible;
if (pointcloudMesh) pointcloudMesh.visible = pointcloudVisible;
document.getElementById('btn-pointcloud').classList.toggle('active', pointcloudVisible);
}};
function getPartingDirection() {{
if (cavityDataGlobal?.metadata?.parting_direction) return cavityDataGlobal.metadata.parting_direction;
if (cavityDataGlobal?.manufacturing_info?.parting_direction) return cavityDataGlobal.manufacturing_info.parting_direction;
if (cavityDataGlobal?.metadata?.is_foam) return 'Z';
return 'Z';
}}
window.splitMold = function() {{
if (!cavityMesh && !coreMesh) return;
isSplit = !isSplit;
const btn = document.getElementById('splitBtn');
btn.textContent = isSplit ? '合模' : '分模拆分';
const dir = getPartingDirection();
let splitDist, axis;
if (dir === 'Z') {{ splitDist = (sceneBox ? sceneBox.getSize(new THREE.Vector3()).z : 100) * 0.4; axis = 'z'; }}
else if (dir === 'Y') {{ splitDist = (sceneBox ? sceneBox.getSize(new THREE.Vector3()).y : 100) * 0.4; axis = 'y'; }}
else {{ splitDist = (sceneBox ? sceneBox.getSize(new THREE.Vector3()).x : 100) * 0.4; axis = 'x'; }}
const partingTargetOpacity = isSplit ? 0 : 0.25;
const cavityStart = cavityMesh ? cavityMesh.position[axis] : 0;
const coreStart = coreMesh ? coreMesh.position[axis] : 0;
const partingStartOpacity = partingMesh ? partingMesh.material.opacity : 0.25;
const cavityTarget = isSplit ? splitDist : 0;
const coreTarget = isSplit ? -splitDist : 0;
const duration = 900;
const startTime = performance.now();
if (splitAnimId) cancelAnimationFrame(splitAnimId);
function animateSplit(now) {{
const elapsed = now - startTime;
const t = Math.min(elapsed / duration, 1);
const ease = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
if (cavityMesh) cavityMesh.position[axis] = cavityStart + (cavityTarget - cavityStart) * ease;
if (coreMesh) coreMesh.position[axis] = coreStart + (coreTarget - coreStart) * ease;
if (partingMesh) {{
partingMesh.material.opacity = partingStartOpacity + (partingTargetOpacity - partingStartOpacity) * ease;
partingMesh.visible = !(isSplit && t >= 1);
}}
if (t < 1) splitAnimId = requestAnimationFrame(animateSplit);
else splitAnimId = null;
}}
splitAnimId = requestAnimationFrame(animateSplit);
}};
</script>
</body>
</html>"""
return html_content
def _build_cavity_info_panel_template(self) -> str:
"""构建型腔信息面板 — 由JS动态填充,这里放置容器"""
return """
<div id="cavity-info-panel">
<h3>🔧 关键工艺参数</h3>
<div style="margin: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 5px;">
<strong style="color: #FF9800;">模具参数</strong>
</div>
<div class="metric"><span class="metric-label">收缩率</span><span class="metric-value" id="cp-shrink">-</span></div>
<div class="metric"><span class="metric-label">拔模角</span><span class="metric-value" id="cp-draft">-</span></div>
<div class="metric"><span class="metric-label">分型线长度</span><span class="metric-value" id="cp-parting">-</span></div>
<div style="margin: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 5px;">
<strong style="color: #FF9800;">几何特性</strong>
</div>
<div class="metric"><span class="metric-label">产品体积</span><span class="metric-value" id="cp-vol">-</span></div>
<div class="metric"><span class="metric-label">产品重量</span><span class="metric-value" id="cp-weight">-</span></div>
<div class="metric"><span class="metric-label">壁厚范围</span><span class="metric-value" id="cp-wall">-</span></div>
<div style="margin: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 5px;">
<strong style="color: #FF9800;">制造要求</strong>
</div>
<div class="metric"><span class="metric-label">模仁材料</span><span class="metric-value" id="cp-material">-</span></div>
<div class="metric"><span class="metric-label">硬度</span><span class="metric-value" id="cp-hardness">-</span></div>
<div class="metric"><span class="metric-label">表面光洁度</span><span class="metric-value" id="cp-finish">-</span></div>
<div class="metric"><span class="metric-label">预估周期</span><span class="metric-value" id="cp-cycle">-</span></div>
</div>
"""
def generate_3d_viewer_data(
self,
geometry_data: Dict[str, Any],
cavity_data: Optional[Dict[str, Any]] = None,
pointcloud_data: Optional[Dict[str, Any]] = None,
lod_data: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""生成companion JSON数据文件内容,支持多级LOD"""
pc = dict(pointcloud_data) if pointcloud_data else {}
if lod_data and lod_data.get("lods"):
pc["lods"] = lod_data["lods"]
data = {
"version": "4.0.0",
"generated_at": datetime.now().isoformat(),
"geometry": geometry_data,
"cavity": cavity_data,
"pointcloud": pc if pc else pointcloud_data,
}
return data
def save_html_file(self, html_content: str, filename: str) -> str:
"""保存HTML文件到磁盘"""
try:
file_path = self.output_dir / filename
file_path.write_text(html_content, encoding='utf-8')
logger.info(f"HTML文件保存成功: {file_path}")
return str(file_path)
except Exception as e:
logger.error(f"保存HTML文件失败: {e}")
raise
def save_data_file(self, data_content: Dict[str, Any], filename: str) -> str:
"""保存JSON数据文件到磁盘 — 使用orjson高速序列化"""
try:
file_path = self.output_dir / filename
file_path.write_bytes(_json_dumps(data_content))
logger.info(f"数据文件保存成功: {file_path} (orjson={_JSON_FAST})")
return str(file_path)
except Exception as e:
logger.error(f"保存数据文件失败: {e}")
raise
def generate_and_save_visualization(
self,
geometry_data: Dict[str, Any],
stp_filename: str,
cavity_data: Optional[Dict[str, Any]] = None,
pointcloud_data: Optional[Dict[str, Any]] = None,
suffix: Optional[str] = None,
lod_data: Optional[Dict[str, Any]] = None,
) -> str:
"""生成并保存可视化HTML + companion JSON数据文件。返回HTML文件路径(向后兼容)"""
try:
base_stem = Path(stp_filename).stem.replace(" ", "_")
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
suffix_part = f"_{suffix}" if suffix else ""
base_name = f"mold_{base_stem}{suffix_part}_{ts}"
html_filename = f"{base_name}.html"
data_filename = f"{base_name}_data.json"
data_content = self.generate_3d_viewer_data(
geometry_data, cavity_data, pointcloud_data, lod_data=lod_data
)
self.save_data_file(data_content, data_filename)
html_content = self.generate_3d_viewer_html(stp_filename, data_filename)
html_file_path = self.save_html_file(html_content, html_filename)
return html_file_path
except Exception as e:
logger.error(f"生成可视化文件失败: {e}")
raise
-5050
View File
File diff suppressed because it is too large Load Diff