x
This commit is contained in:
@@ -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
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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": "库存记录已删除"}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
@@ -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}"
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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})>"
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user