feat: v0.3 报价算料引擎与仓库库存模块

后端:
- 报价算料: 从drawing_data几何提取型材清单(外框/中梃/扇框投影长度)
  与玻璃面积(鞋带公式), 套价格本计价, 支持样板/订单/自定义图纸三入口
- 仓库库存: 材料CRUD/出入库/流水/负库存拦截/预警线
- SQLite默认路径锚定项目根目录(修复cwd依赖)
前端:
- 仓库库存页(筛选/出入库对话框/预警高亮)
- 算料报价弹窗(样板库与订单详情入口, 支持打印)
- 路由与导航更新(8页面)
回归: 9个API端点全部200, 报价/优化/SPA路由验证通过
This commit is contained in:
HHY
2026-09-20 08:58:14 -07:00
parent f7a555dd6c
commit 065d387399
16 changed files with 630 additions and 8 deletions
+45
View File
@@ -0,0 +1,45 @@
"""报价路由:从 drawing_data 算料计价(对齐目标站 offer-price 语义,自研实现)。"""
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from huamenchuang.core.deps import CurrentUser, DbSession, require_module
from huamenchuang.core.response import fail, ok
from huamenchuang.services.quote_service import quote as calc_quote
router = APIRouter(prefix="/offer-price", tags=["报价"],
dependencies=[Depends(require_module("quote"))])
class QuoteRequest(BaseModel):
drawing_data: str | dict | None = Field(default=None, description="画布 drawing_data(与 template_id/order_id 三选一)")
template_id: int | None = Field(default=None, description="或直接指定样板")
order_id: int | None = Field(default=None, description="或对已有订单报价")
prices: dict | None = Field(default=None, description="价格本覆盖 {frame,mullion,sash,glass,hardware}")
async def _resolve_drawing(db, body: QuoteRequest) -> str | dict | None:
if body.drawing_data:
return body.drawing_data
if body.template_id:
from sqlalchemy import select
from huamenchuang.models.windoor_template import WindoorTemplate
t = (await db.execute(select(WindoorTemplate).where(WindoorTemplate.id == body.template_id))).scalar_one_or_none()
return t.drawing_data if t else None
if body.order_id:
from huamenchuang.models.order import Order
o = await db.get(Order, body.order_id)
return o.drawing_data if o else None
return None
@router.post("")
async def offer_price(body: QuoteRequest, user: CurrentUser, db: DbSession):
"""算料报价:型材清单 + 玻璃面积 + 五金 + 总价。"""
drawing = await _resolve_drawing(db, body)
if drawing is None:
return fail("未找到图纸数据")
try:
result = calc_quote(drawing, body.prices)
except Exception:
return fail("图纸数据解析失败")
return ok(data=result, message="报价成功")
+12 -1
View File
@@ -1,7 +1,16 @@
"""路由汇总。"""
from fastapi import APIRouter
from huamenchuang.api import auth, contracts, customers, optimize, orders, windoor
from huamenchuang.api import (
auth,
contracts,
customers,
optimize,
orders,
quote,
warehouse,
windoor,
)
api_router = APIRouter(prefix="/api")
api_router.include_router(auth.router)
@@ -10,3 +19,5 @@ api_router.include_router(customers.router)
api_router.include_router(contracts.router)
api_router.include_router(windoor.router)
api_router.include_router(optimize.router)
api_router.include_router(quote.router)
api_router.include_router(warehouse.router)
+109
View File
@@ -0,0 +1,109 @@
"""仓库库存路由:材料 CRUD + 出入库。"""
from fastapi import APIRouter
from pydantic import BaseModel, Field
from sqlalchemy import desc, select
from huamenchuang.core.deps import CurrentUser, DbSession
from huamenchuang.core.response import fail, ok, paginated
from huamenchuang.models.contract import StockMove, WarehouseItem
router = APIRouter(prefix="/warehouse", tags=["仓库"])
class ItemBody(BaseModel):
name: str = Field(min_length=1, max_length=128)
bom_code: str = ""
material: str = Field(default="profile", pattern="^(profile|glass|hardware|addon)$")
color: str = ""
spec: str = ""
unit: str = "根"
cost: float = 0
price: float = 0
count: float = 0
warning: float = 0
length: int = 0
note: str = ""
class MoveBody(BaseModel):
item_id: int
direction: int = Field(default=1, ge=-1, le=1) # 1入库 -1出库
count: float = Field(gt=0)
note: str = ""
def _out(i: WarehouseItem) -> dict:
return {
"id": i.id, "bom_code": i.bom_code, "name": i.name, "material": i.material,
"color": i.color, "spec": i.spec, "unit": i.unit, "cost": float(i.cost),
"price": float(i.price), "count": float(i.count), "warning": float(i.warning),
"length": i.length, "note": i.note, "updated_at": str(i.updated_at),
}
@router.get("/list")
async def list_items(user: CurrentUser, db: DbSession, page: int = 1, page_size: int = 15,
keyword: str = "", material: str | None = None):
"""材料库存列表(分页 + 筛选,对齐目标站 warehouse_list 语义)。"""
from sqlalchemy import func, or_
conds = [WarehouseItem.company_id == user.company_id]
if material:
conds.append(WarehouseItem.material == material)
if kw := keyword.strip():
like = f"%{kw}%"
conds.append(or_(WarehouseItem.name.like(like), WarehouseItem.bom_code.like(like)))
where = *conds,
total = (await db.execute(select(func.count()).select_from(WarehouseItem).where(*where))).scalar_one()
stmt = (select(WarehouseItem).where(*where).order_by(WarehouseItem.id.desc())
.offset((page - 1) * page_size).limit(page_size))
items = list((await db.execute(stmt)).scalars())
return ok(data=paginated([_out(i) for i in items], page, page_size, total))
@router.post("")
async def create_item(body: ItemBody, user: CurrentUser, db: DbSession):
"""新增材料。"""
item = WarehouseItem(company_id=user.company_id, **body.model_dump())
db.add(item)
await db.flush()
return ok(data={"id": item.id}, message="创建成功")
@router.put("/{item_id}")
async def update_item(item_id: int, body: ItemBody, user: CurrentUser, db: DbSession):
"""编辑材料。"""
item = await db.get(WarehouseItem, item_id)
if item is None or item.company_id != user.company_id:
return fail("材料不存在")
for k, v in body.model_dump().items():
setattr(item, k, v)
return ok(message="更新成功")
@router.post("/move")
async def stock_move(body: MoveBody, user: CurrentUser, db: DbSession):
"""出入库:direction=1 入库 / -1 出库(出库不允许负库存)。"""
item = await db.get(WarehouseItem, body.item_id)
if item is None or item.company_id != user.company_id:
return fail("材料不存在")
delta = body.count if body.direction == 1 else -body.count
if float(item.count) + delta < 0:
return fail(f"库存不足: 当前 {float(item.count)}{item.unit}")
item.count = float(item.count) + delta
db.add(StockMove(item_id=item.id, direction=body.direction, count=body.count,
note=body.note, user_id=user.id))
return ok(data={"count": float(item.count)}, message="操作成功")
@router.get("/moves")
async def move_logs(user: CurrentUser, db: DbSession, page: int = 1, page_size: int = 20):
"""出入库流水。"""
total_q = select(func.count()).select_from(StockMove)
total = (await db.execute(total_q)).scalar_one()
stmt = (select(StockMove).order_by(desc(StockMove.id))
.offset((page - 1) * page_size).limit(page_size))
moves = list((await db.execute(stmt)).scalars())
return ok(data=paginated([{
"id": m.id, "item_id": m.item_id, "direction": m.direction, "count": float(m.count),
"note": m.note, "created_at": str(m.created_at),
} for m in moves], page, page_size, total))