Files
geMoldInsight/src/inventory/api/material_routes.py
T
cjw 0e6b3b1811 后端设计治理:批次 0-4 全部完成(安全/部署/一致性/结构/架构)
按 ROADMAP §3.1 治理批次推进的后端设计审查整改:

- 批次 0(安全):/api/status/{task_id} 补 JWT 鉴权与任务归属校验;
  pythonocc_available 真实探测;bcrypt 超 72 字节显式拒绝;
  SECRET_KEY/RUSTFS_* 惰性校验,代码侧弱默认移除
- 批次 1(部署正确性):主处理链路改走 RustFS(分派入参 stp_file_id 化,
  worker 按 object_key 下载);AUTO_MIGRATE 开关 + 迁移目录 alembic/→migrations/
  修复包遮蔽(自动迁移此前从未真正生效);OCC 镜像改 conda 原生执行 +
  基础镜像 tag 锁定;compose 关键项改 ${VAR:?} 强制显式配置
- 批次 2(任务一致性):删除 Redis 进程内存回退,PG 为任务状态单一事实源;
  批量元数据入库(processing_tasks.batch_id,迁移 a3f8c2d91e47);
  型腔失败任务标 failed 不再静默 completed;事务边界收口
  (数据本体写 flush-only、失败先回滚再置 failed、进度更新保留即时 commit)
- 批次 3(API 与代码结构):592 行 advanced_router 拆为 design/cost/machining/
  export 四子路由,请求体全量 Pydantic 化;ROUTE_MODULES + route_registry
  (/api/health 呈现 degraded,DEBUG fail fast);纯计算端点统一 to_thread;
  StorageIntegrationService 按职责三拆;MAX_FILE_SIZE 接线生效、
  celery 复用 Settings.redis_url;管理员重置密码改 JSON body(端到端断裂修复);
  openapi.json 重导出(76 paths)+ 前端 gen:api
- 批次 4(架构演进):共享 ORM 按模块拆分(shared/models/base.py + identity.py、
  moldinsight/models/、inventory/models/,删除三条无使用方的跨模块
  relationship,跨模块桥接收敛为裸 FK 硬规则,无兼容 facade);
  OCC executor 重建补 cancel_futures=True(消除旧队列被慢恢复线程
  并行消化的数据竞争);OCC 吞吐方案设计先行
  (docs/topics/performance/OCC_THROUGHPUT.md);顺手清偿 D15
  (vite.config.ts 未用参数致 npm run build 失败)

测试基线:125 passed, 2 skipped(pytest + sqlite+aiosqlite;归属边界、
路由契约、配置治理、鉴权回归等随批新增)
文档同步:STATUS / TECH_DEBT / ROADMAP / ARCHITECTURE / API_CONTRACT /
OPERATIONS / AGENTS

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 16:15:49 +08:00

329 lines
12 KiB
Python

"""
物料管理路由模块
提供物料价格历史和供应商关联管理功能,包括:
- 物料价格历史记录
- 物料供应商关联管理
- 物料价格趋势分析
路由前缀: /api/materials
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc
from typing import Optional, List
from shared.database.database import get_db_session
from shared.services.auth_service import get_current_active_user
from shared.models.identity import User
from inventory.models import Product, MaterialPriceHistory, MaterialSupplier, Supplier
from ..schemas import (
MaterialPriceHistoryCreate,
MaterialPriceHistoryResponse,
MaterialSupplierCreate,
MaterialSupplierResponse,
MaterialPriceTrendResponse
)
router = APIRouter(prefix="/materials", tags=["物料管理"])
async def _get_product(db_session: AsyncSession, product_id: int) -> Product:
result = await db_session.execute(
select(Product).where(Product.id == product_id, Product.is_active == True)
)
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="仅物料类型支持价格历史管理")
return product
@router.post("/{product_id}/price-history", response_model=MaterialPriceHistoryResponse, status_code=201)
async def add_material_price_history(
product_id: int,
price_data: MaterialPriceHistoryCreate,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
product = await _get_product(db_session, product_id)
# 检查供应商是否存在
if price_data.supplier_id:
supplier_result = await db_session.execute(
select(Supplier).where(Supplier.id == price_data.supplier_id, Supplier.is_active == True)
)
if not supplier_result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="供应商不存在")
price_history = MaterialPriceHistory(
product_id=product_id,
price=price_data.price,
supplier_id=price_data.supplier_id,
remark=price_data.remark
)
db_session.add(price_history)
await db_session.flush()
await db_session.refresh(price_history)
# 更新产品的成本价格为最新价格
product.cost_price = price_data.price
await db_session.flush()
return MaterialPriceHistoryResponse(
id=price_history.id,
product_id=price_history.product_id,
product_sku=product.sku,
product_name=product.name,
price=price_history.price,
effective_date=price_history.effective_date,
supplier_id=price_history.supplier_id,
supplier_name=price_history.supplier.name if price_history.supplier else None,
remark=price_history.remark,
created_at=price_history.created_at
)
@router.get("/{product_id}/price-history", response_model=List[MaterialPriceHistoryResponse])
async def get_material_price_history(
product_id: int,
limit: int = Query(20, ge=1, le=100),
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
product = await _get_product(db_session, product_id)
result = await db_session.execute(
select(MaterialPriceHistory)
.where(MaterialPriceHistory.product_id == product_id)
.order_by(desc(MaterialPriceHistory.effective_date))
.limit(limit)
)
price_history_list = result.scalars().all()
return [
MaterialPriceHistoryResponse(
id=ph.id,
product_id=ph.product_id,
product_sku=product.sku,
product_name=product.name,
price=ph.price,
effective_date=ph.effective_date,
supplier_id=ph.supplier_id,
supplier_name=ph.supplier.name if ph.supplier else None,
remark=ph.remark,
created_at=ph.created_at
)
for ph in price_history_list
]
@router.get("/{product_id}/price-trend", response_model=MaterialPriceTrendResponse)
async def get_material_price_trend(
product_id: int,
months: int = Query(6, ge=1, le=24),
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
product = await _get_product(db_session, product_id)
# 计算价格趋势
result = await db_session.execute(
select(MaterialPriceHistory)
.where(MaterialPriceHistory.product_id == product_id)
.order_by(desc(MaterialPriceHistory.effective_date))
.limit(months)
)
price_history_list = result.scalars().all()
if not price_history_list:
raise HTTPException(status_code=404, detail="无价格历史记录")
prices = [ph.price for ph in reversed(price_history_list)]
dates = [ph.effective_date for ph in reversed(price_history_list)]
# 计算价格变化
current_price = price_history_list[0].price
first_price = price_history_list[-1].price
price_change = current_price - first_price
price_change_percent = (price_change / first_price * 100) if first_price > 0 else 0
return MaterialPriceTrendResponse(
product_id=product_id,
product_sku=product.sku,
product_name=product.name,
current_price=current_price,
price_change=round(price_change, 2),
price_change_percent=round(price_change_percent, 2),
price_history=[
{
"date": ph.effective_date,
"price": ph.price,
"supplier_name": ph.supplier.name if ph.supplier else None
}
for ph in price_history_list
]
)
@router.post("/{product_id}/suppliers", response_model=MaterialSupplierResponse, status_code=201)
async def add_material_supplier(
product_id: int,
supplier_data: MaterialSupplierCreate,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
product = await _get_product(db_session, product_id)
# 检查供应商是否存在
supplier_result = await db_session.execute(
select(Supplier).where(Supplier.id == supplier_data.supplier_id, Supplier.is_active == True)
)
supplier = supplier_result.scalar_one_or_none()
if not supplier:
raise HTTPException(status_code=400, detail="供应商不存在")
# 检查是否已存在关联
existing = await db_session.execute(
select(MaterialSupplier)
.where(
MaterialSupplier.product_id == product_id,
MaterialSupplier.supplier_id == supplier_data.supplier_id
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="该供应商已关联到该物料")
# 如果设置为主要供应商,将其他供应商设置为非主要
if supplier_data.is_primary:
await db_session.execute(
MaterialSupplier.__table__.update()
.where(MaterialSupplier.product_id == product_id)
.values(is_primary=False)
)
material_supplier = MaterialSupplier(
product_id=product_id,
supplier_id=supplier_data.supplier_id,
is_primary=supplier_data.is_primary,
contact_person=supplier_data.contact_person,
contact_phone=supplier_data.contact_phone,
lead_time=supplier_data.lead_time,
min_order_quantity=supplier_data.min_order_quantity
)
db_session.add(material_supplier)
await db_session.flush()
await db_session.refresh(material_supplier)
return MaterialSupplierResponse(
id=material_supplier.id,
product_id=material_supplier.product_id,
product_sku=product.sku,
product_name=product.name,
supplier_id=material_supplier.supplier_id,
supplier_name=supplier.name,
is_primary=material_supplier.is_primary,
contact_person=material_supplier.contact_person,
contact_phone=material_supplier.contact_phone,
lead_time=material_supplier.lead_time,
min_order_quantity=material_supplier.min_order_quantity,
created_at=material_supplier.created_at,
updated_at=material_supplier.updated_at
)
@router.get("/{product_id}/suppliers", response_model=List[MaterialSupplierResponse])
async def get_material_suppliers(
product_id: int,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
product = await _get_product(db_session, product_id)
result = await db_session.execute(
select(MaterialSupplier)
.where(MaterialSupplier.product_id == product_id)
.order_by(MaterialSupplier.is_primary.desc(), MaterialSupplier.id.asc())
)
supplier_list = result.scalars().all()
return [
MaterialSupplierResponse(
id=ms.id,
product_id=ms.product_id,
product_sku=product.sku,
product_name=product.name,
supplier_id=ms.supplier_id,
supplier_name=ms.supplier.name if ms.supplier else None,
is_primary=ms.is_primary,
contact_person=ms.contact_person,
contact_phone=ms.contact_phone,
lead_time=ms.lead_time,
min_order_quantity=ms.min_order_quantity,
created_at=ms.created_at,
updated_at=ms.updated_at
)
for ms in supplier_list
]
@router.delete("/suppliers/{supplier_id}")
async def remove_material_supplier(
supplier_id: int,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
result = await db_session.execute(
select(MaterialSupplier).where(MaterialSupplier.id == supplier_id)
)
material_supplier = result.scalar_one_or_none()
if not material_supplier:
raise HTTPException(status_code=404, detail="物料供应商关联不存在")
await db_session.delete(material_supplier)
await db_session.flush()
return {"message": "物料供应商关联已删除"}
@router.get("/suppliers/{supplier_id}/materials", response_model=List[MaterialSupplierResponse])
async def get_supplier_materials(
supplier_id: int,
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 == supplier_id, Supplier.is_active == True)
)
supplier = supplier_result.scalar_one_or_none()
if not supplier:
raise HTTPException(status_code=404, detail="供应商不存在")
result = await db_session.execute(
select(MaterialSupplier)
.where(MaterialSupplier.supplier_id == supplier_id)
.order_by(MaterialSupplier.is_primary.desc(), MaterialSupplier.id.asc())
)
material_list = result.scalars().all()
return [
MaterialSupplierResponse(
id=ms.id,
product_id=ms.product_id,
product_sku=ms.product.sku if ms.product else None,
product_name=ms.product.name if ms.product else None,
supplier_id=ms.supplier_id,
supplier_name=supplier.name,
is_primary=ms.is_primary,
contact_person=ms.contact_person,
contact_phone=ms.contact_phone,
lead_time=ms.lead_time,
min_order_quantity=ms.min_order_quantity,
created_at=ms.created_at,
updated_at=ms.updated_at
)
for ms in material_list
]