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))
+7 -2
View File
@@ -1,11 +1,16 @@
"""全局配置。开发默认 SQLite,生产可通过环境变量切 MySQL。"""
"""全局配置。开发默认 SQLite(锚定项目根,避免 cwd 影响),生产可通过环境变量切 MySQL。"""
from pathlib import Path
from pydantic_settings import BaseSettings
_PROJECT_ROOT = Path(__file__).resolve().parents[3]
_DEFAULT_DB = f"sqlite+aiosqlite:///{(_PROJECT_ROOT / 'huamenchuang.db').as_posix()}"
class Settings(BaseSettings):
app_name: str = "huamenchuang"
# 开发默认 SQLite;生产示例: mysql+aiomysql://user:pwd@localhost/hmc
database_url: str = "sqlite+aiosqlite:///./huamenchuang.db"
database_url: str = _DEFAULT_DB
jwt_secret: str = "change-me-in-production"
jwt_algorithm: str = "HS256"
jwt_expire_hours: int = 24
+23
View File
@@ -0,0 +1,23 @@
"""drawing_data 的 Python 侧解析(与前端 types/drawing.ts 同构的最小模型)。"""
import json
from typing import Any, TypedDict
class FrameShape(TypedDict, total=False):
type: str
polygon: dict
fm: dict
mm: dict
cm: dict
sm: list
class DrawingData(TypedDict, total=False):
s: dict
sm: list
def parse_drawing(raw: str | dict) -> DrawingData:
"""解析 drawing_data(兼容 JSON 字符串或对象)。"""
dd = json.loads(raw) if isinstance(raw, str) else raw
return dd # type: ignore[return-value]
+10 -1
View File
@@ -1,5 +1,11 @@
"""ORM 模型统一导出。"""
from huamenchuang.models.contract import Contract, Customer
from huamenchuang.models.contract import (
Contract,
Customer,
ReceiptAccount,
StockMove,
WarehouseItem,
)
from huamenchuang.models.order import DEFAULT_PROCESSES, Order, OrderLog, OrderProcess
from huamenchuang.models.user import User
from huamenchuang.models.windoor_template import WindoorCatelog, WindoorTemplate
@@ -11,7 +17,10 @@ __all__ = [
"Order",
"OrderLog",
"OrderProcess",
"ReceiptAccount",
"StockMove",
"User",
"WarehouseItem",
"WindoorCatelog",
"WindoorTemplate",
]
+57
View File
@@ -25,6 +25,63 @@ class Customer(Base):
)
class ReceiptAccount(Base):
"""收款账户。"""
__tablename__ = "receipt_accounts"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
company_id: Mapped[int] = mapped_column(default=1, index=True)
type: Mapped[int] = mapped_column(default=1) # 1银行 2现金 3其他
name: Mapped[str] = mapped_column(String(64))
number: Mapped[str] = mapped_column(String(64), default="")
bank: Mapped[str] = mapped_column(String(64), default="")
note: Mapped[str] = mapped_column(Text, default="")
is_default: Mapped[int] = mapped_column(default=0)
balance: Mapped[float] = mapped_column(Numeric(12, 2), default=0)
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
class WarehouseItem(Base):
"""材料库存(型材/玻璃/五金/配件)。"""
__tablename__ = "warehouse_items"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
company_id: Mapped[int] = mapped_column(default=1, index=True)
bom_code: Mapped[str] = mapped_column(String(32), default="")
name: Mapped[str] = mapped_column(String(128))
# profile型材/glass玻璃/hardware五金/addon配件
material: Mapped[str] = mapped_column(String(16), default="profile", index=True)
color: Mapped[str] = mapped_column(String(32), default="")
spec: Mapped[str] = mapped_column(String(64), default="") # 规格型号
unit: Mapped[str] = mapped_column(String(8), default="根")
cost: Mapped[float] = mapped_column(Numeric(12, 2), default=0)
price: Mapped[float] = mapped_column(Numeric(12, 2), default=0)
count: Mapped[float] = mapped_column(Numeric(12, 2), default=0)
warning: Mapped[float] = mapped_column(Numeric(12, 2), default=0) # 库存预警线
length: Mapped[int] = mapped_column(default=0) # 单根长度mm(型材)
note: Mapped[str] = mapped_column(Text, default="")
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
updated_at: Mapped[datetime.datetime] = mapped_column(
DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow
)
class StockMove(Base):
"""出入库流水。direction: 1入库 -1出库"""
__tablename__ = "stock_moves"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
item_id: Mapped[int] = mapped_column(ForeignKey("warehouse_items.id"), index=True)
direction: Mapped[int] = mapped_column(default=1)
count: Mapped[float] = mapped_column(Numeric(12, 2), default=0)
note: Mapped[str] = mapped_column(String(255), default="")
user_id: Mapped[int] = mapped_column(default=0)
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
class Contract(Base):
"""合同/意向单:目标站契约 status="意向单" 起步,oids[] 记录关联订单。"""
+147
View File
@@ -0,0 +1,147 @@
"""报价算料服务:从 drawing_data 几何信息提取型材下料清单与玻璃面积,套价格本计价。
提取规则(自研 v1,工程近似):
- 外框:每 Frame 形状 2 根宽 + 2 根高(忽略 45° 拼角损耗)
- 中梃:每条分割线按其在框内投影长度计(竖梃=框高,横梃=框宽)
- 扇框:每个 Sash 4 根(2×扇宽 + 2×扇高)
- 玻璃:全部玻璃区域面积之和(鞋带公式)
"""
from dataclasses import dataclass, field
from huamenchuang.core.drawing import DrawingData, parse_drawing
@dataclass
class BarItem:
"""型材清单行。"""
kind: str # frame/mullion/sash
length: int # mm
qty: int = 1
note: str = ""
@dataclass
class TakeoffResult:
bars: list[BarItem] = field(default_factory=list)
glass_area_m2: float = 0.0
width_mm: int = 0
height_mm: int = 0
def _shoelace_area(pts: list[tuple[float, float]]) -> float:
"""多边形面积(鞋带公式)。"""
n = len(pts)
s = 0.0
for i in range(n):
x1, y1 = pts[i]
x2, y2 = pts[(i + 1) % n]
s += x1 * y2 - x2 * y1
return abs(s) / 2
def _region_bbox(region_pts):
xs = [p[0] for p in region_pts]
ys = [p[1] for p in region_pts]
return min(xs), min(ys), max(xs), max(ys)
def takeoff(dd: DrawingData) -> TakeoffResult:
"""从解析后的 drawing_data 提取算料清单。"""
result = TakeoffResult()
xs, ys = [], []
for shape in dd.get("sm", []):
regions = [[(seg["ps"]["x"], seg["ps"]["y"]) for seg in r]
for r in ((shape.get("polygon") or {}).get("polygon") or [])]
if not regions:
continue
# 形状外框 bbox
all_pts = [p for r in regions for p in r]
x0, y0, x1, y1 = _region_bbox(all_pts)
w, h = round(x1 - x0), round(y1 - y0)
xs.extend([x0, x1])
ys.extend([y0, y1])
result.bars.append(BarItem("frame", w, 2, "外框-宽"))
result.bars.append(BarItem("frame", h, 2, "外框-高"))
# 中梃:投影长度(竖梃=框高,横梃=框宽)
for sp in ((shape.get("mm") or {}).get("sp") or []):
norm = sp["line"]["norm"]
if abs(norm["x"]) >= abs(norm["y"]):
result.bars.append(BarItem("mullion", h, 1, "竖梃"))
else:
result.bars.append(BarItem("mullion", w, 1, "横梃"))
# 玻璃面积
result.glass_area_m2 += sum(_shoelace_area(r) for r in regions) / 1_000_000
# 扇框
for sub in (shape.get("sm") or []):
bd = sub.get("bd") if isinstance(sub, dict) else None
if not bd or bd.get("hidden"):
continue
bw = round(abs(bd["to"]["x"] - bd["from"]["x"]))
bh = round(abs(bd["to"]["y"] - bd["from"]["y"]))
if bw < 10 or bh < 10:
continue
result.bars.append(BarItem("sash", bw, 2, "扇框-宽"))
result.bars.append(BarItem("sash", bh, 2, "扇框-高"))
if xs:
result.width_mm = round(max(xs) - min(xs))
result.height_mm = round(max(ys) - min(ys))
return result
def merge_bars(bars: list[BarItem]) -> list[dict]:
"""同长度同类合并。"""
merged: dict[tuple, dict] = {}
for b in bars:
key = (b.kind, b.length, b.note)
if key in merged:
merged[key]["qty"] += b.qty
else:
merged[key] = {"kind": b.kind, "length": b.length, "note": b.note, "qty": b.qty}
return sorted(merged.values(), key=lambda x: -x["length"])
def quote(drawing: str | dict, prices: dict | None = None) -> dict:
"""完整报价:算料 + 套价。
prices 结构: {frame: 元/米, mullion: 元/米, sash: 元/米, glass: 元/㎡, hardware: 元/扇}
"""
p = {"frame": 45, "mullion": 40, "sash": 50, "glass": 120, "hardware": 180}
if prices:
p.update(prices)
dd = parse_drawing(drawing)
t = takeoff(dd)
rows, total = [], 0.0
unit_kind = {"frame": "frame", "mullion": "mullion", "sash": "sash"}
for b in merge_bars(t.bars):
amount = b["length"] * b["qty"] / 1000 * p[unit_kind[b["kind"]]]
total += amount
rows.append({**b, "unit_price": p[unit_kind[b["kind"]]], "unit": "元/米", "amount": round(amount, 2)})
glass_amount = t.glass_area_m2 * p["glass"]
total += glass_amount
rows.append({"kind": "glass", "length": 0, "note": "玻璃", "qty": round(t.glass_area_m2, 3),
"unit_price": p["glass"], "unit": "元/㎡", "amount": round(glass_amount, 2)})
sash_count = sum(
1 for s in dd.get("sm", []) for sub in (s.get("sm") or [])
if isinstance(sub, dict) and sub.get("bd") and not sub.get("bd").get("hidden")
)
hw_amount = sash_count * p["hardware"]
if sash_count:
rows.append({"kind": "hardware", "length": 0, "note": "五金", "qty": sash_count,
"unit_price": p["hardware"], "unit": "元/扇", "amount": round(hw_amount, 2)})
total += hw_amount
return {
"rows": rows,
"glass_area_m2": round(t.glass_area_m2, 3),
"width_mm": t.width_mm,
"height_mm": t.height_mm,
"total": round(total, 2),
}