feat: P1批次1 设计域(AI推荐/分享体系/阳光房绘图/量尺标注/逐格指定编辑)
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
"""AI 推荐窗型路由(对齐目标站 ai_recommend:按需求推荐样板,规则引擎确定性打分)。"""
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
|
||||
from huamenchuang.core.deps import CurrentUser, DbSession
|
||||
from huamenchuang.core.response import fail, ok
|
||||
from huamenchuang.core.drawing import parse_drawing, replay_shape
|
||||
from huamenchuang.models.windoor_template import WindoorTemplate
|
||||
|
||||
router = APIRouter(prefix="/ai-recommend", tags=["AI 推荐"])
|
||||
|
||||
|
||||
class RecommendBody(BaseModel):
|
||||
width_mm: int = Field(ge=300, le=20000, description="洞口宽度 mm")
|
||||
height_mm: int = Field(ge=300, le=20000, description="洞口高度 mm")
|
||||
room: str = Field(default="living", pattern="^(living|bedroom|balcony|kitchen|bath|shop)$",
|
||||
description="房间: 客厅/卧室/阳台/厨房/卫浴/商铺")
|
||||
needs: list[str] = Field(default_factory=list,
|
||||
description="需求标签: ventilation通风/light采光/sound隔音/safe安全/view景观/door通行")
|
||||
|
||||
|
||||
# 需求标签 → 加分特征
|
||||
_TAG_RULES = {
|
||||
"ventilation": ("开扇", 3.0, "需要通风,优先带开启扇"),
|
||||
"light": ("玻璃面积", 2.0, "需要采光,优先大玻璃分格"),
|
||||
"sound": ("隔音", 2.0, "需要隔音,优先少开扇+固定大格"),
|
||||
"safe": ("安全", 1.5, "需要安全,优先内开内倒/纱扇"),
|
||||
"view": ("景观", 1.5, "需要景观面,优先大板玻璃"),
|
||||
"door": ("通行", 3.0, "需要通行,优先门/推拉类"),
|
||||
}
|
||||
|
||||
_ROOM_HINT = {
|
||||
"living": ("客厅", ["view", "light", "ventilation"]),
|
||||
"bedroom": ("卧室", ["ventilation", "sound", "safe"]),
|
||||
"balcony": ("阳台", ["door", "ventilation", "light"]),
|
||||
"kitchen": ("厨房", ["ventilation", "light"]),
|
||||
"bath": ("卫浴", ["ventilation", "safe"]),
|
||||
"shop": ("商铺", ["door", "view", "light"]),
|
||||
}
|
||||
|
||||
|
||||
def _template_features(dd: dict) -> dict:
|
||||
"""提取样板特征: 宽高比/玻璃面积比/开扇数/是否门类/是否有纱扇。"""
|
||||
try:
|
||||
parsed = parse_drawing(dd)
|
||||
except Exception:
|
||||
return {}
|
||||
xs, ys, glass_area = [], [], 0.0
|
||||
sashes = 0
|
||||
is_door = False
|
||||
has_screen = False
|
||||
for shape in parsed.get("sm", []):
|
||||
if not isinstance(shape, dict):
|
||||
continue
|
||||
regions = replay_shape(shape)
|
||||
pts = [[(seg["ps"]["x"], seg["ps"]["y"]) for seg in r] for r in regions]
|
||||
if not pts:
|
||||
continue
|
||||
flat = [p for r in pts for p in r]
|
||||
xs += [min(p[0] for p in flat), max(p[0] for p in flat)]
|
||||
ys += [min(p[1] for p in flat), max(p[1] for p in flat)]
|
||||
for r in pts:
|
||||
a = 0.0
|
||||
for i in range(len(r)):
|
||||
x1, y1 = r[i]
|
||||
x2, y2 = r[(i + 1) % len(r)]
|
||||
a += x1 * y2 - x2 * y1
|
||||
glass_area += abs(a) / 2
|
||||
for sub in (shape.get("sm") or []):
|
||||
if not isinstance(sub, dict):
|
||||
continue
|
||||
t = str(sub.get("type", ""))
|
||||
if "Screen" in t:
|
||||
has_screen = True
|
||||
continue
|
||||
if "Slide" in t:
|
||||
sashes += 2
|
||||
elif sub.get("bd") and not (sub.get("bd") or {}).get("hidden"):
|
||||
sashes += 1
|
||||
if sub.get("isDoor"):
|
||||
is_door = True
|
||||
if not xs:
|
||||
return {}
|
||||
w, h = max(xs) - min(xs), max(ys) - min(ys)
|
||||
frame_area = w * h
|
||||
return {"ratio": w / h if h else 1, "glass_ratio": glass_area / frame_area if frame_area else 0,
|
||||
"sashes": sashes, "is_door": is_door, "has_screen": has_screen}
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def recommend(body: RecommendBody, user: CurrentUser, db: DbSession):
|
||||
"""按洞口尺寸+房间+需求标签推荐样板 TOP5(规则打分,可解释理由)。"""
|
||||
stmt = select(WindoorTemplate).where(WindoorTemplate.is_default == 1)
|
||||
templates = list((await db.execute(stmt)).scalars())
|
||||
if not templates:
|
||||
return fail("样板库为空")
|
||||
room_name, default_tags = _ROOM_HINT.get(body.room, ("客厅", ["light"]))
|
||||
tags = list(dict.fromkeys((body.needs or []) + default_tags))
|
||||
|
||||
target_ratio = body.width_mm / body.height_mm
|
||||
scored = []
|
||||
for t in templates[:80]: # 打分上限控制计算量(前 80 套按 id 序,已覆盖六大类)
|
||||
if not t.drawing_data:
|
||||
continue
|
||||
try:
|
||||
dd = t.drawing_data if isinstance(t.drawing_data, dict) else json.loads(t.drawing_data)
|
||||
except Exception:
|
||||
continue
|
||||
f = _template_features(dd)
|
||||
if not f:
|
||||
continue
|
||||
score, reasons = 0.0, []
|
||||
# 尺寸适配: 宽高比接近度(0~4 分) + 洞口在模板可调范围
|
||||
r_diff = abs(f["ratio"] - target_ratio) / max(target_ratio, 0.01)
|
||||
score += max(0.0, 4.0 - r_diff * 4.0)
|
||||
if r_diff < 0.15:
|
||||
reasons.append("宽高比例与洞口匹配")
|
||||
# 标签规则
|
||||
if "ventilation" in tags:
|
||||
v = min(f["sashes"], 3) * 1.2
|
||||
score += v
|
||||
if v >= 1.2:
|
||||
reasons.append(f"带 {f['sashes']} 个开启扇,通风好")
|
||||
if "light" in tags:
|
||||
v = f["glass_ratio"] * 4.0
|
||||
score += v
|
||||
if f["glass_ratio"] > 0.6:
|
||||
reasons.append("大玻璃面积,采光佳")
|
||||
if "sound" in tags:
|
||||
if f["sashes"] <= 1:
|
||||
score += 2.0
|
||||
reasons.append("固定大格为主,隔音好")
|
||||
if "safe" in tags:
|
||||
if f["has_screen"]:
|
||||
score += 1.5
|
||||
reasons.append("带纱扇防蚊安全")
|
||||
if "view" in tags:
|
||||
if f["glass_ratio"] > 0.65 and f["sashes"] <= 2:
|
||||
score += 2.0
|
||||
reasons.append("大板玻璃,景观视野好")
|
||||
if "door" in tags:
|
||||
if f["is_door"] or f["ratio"] >= 1.2:
|
||||
score += 3.0
|
||||
if f["is_door"]:
|
||||
reasons.append("门类窗型,可通行")
|
||||
if not reasons:
|
||||
reasons.append("综合分格均衡")
|
||||
scored.append((score, t, reasons[:3]))
|
||||
scored.sort(key=lambda x: -x[0])
|
||||
items = [{
|
||||
"id": t.id, "name": t.name, "pic_url": t.pic_url, "catelog_id": t.catelog_id,
|
||||
"score": round(s, 2), "reasons": rs,
|
||||
} for s, t, rs in scored[:5]]
|
||||
return ok(data={"items": items, "room": room_name, "tags": tags,
|
||||
"summary": f"按{room_name}场景({'、'.join(tags)})从 {len(scored)} 套样板中推荐"},
|
||||
message="推荐完成")
|
||||
@@ -13,8 +13,11 @@ from huamenchuang.api import (
|
||||
orders,
|
||||
platform,
|
||||
quote,
|
||||
recommend,
|
||||
reports,
|
||||
script,
|
||||
shares,
|
||||
sunroom,
|
||||
warehouse,
|
||||
windoor,
|
||||
)
|
||||
@@ -31,6 +34,9 @@ api_router.include_router(script.router)
|
||||
api_router.include_router(order_format.router)
|
||||
api_router.include_router(finance.router)
|
||||
api_router.include_router(ai_render.router)
|
||||
api_router.include_router(shares.router)
|
||||
api_router.include_router(recommend.router)
|
||||
api_router.include_router(sunroom.router)
|
||||
api_router.include_router(warehouse.router)
|
||||
api_router.include_router(business.router)
|
||||
api_router.include_router(platform.router)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""通用分享路由:渲染图/样板组合/报价单/阳光房设计分享(公开访问,HMAC token)。"""
|
||||
import json
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
|
||||
from huamenchuang.core.deps import CurrentUser, DbSession
|
||||
from huamenchuang.core.response import fail, ok
|
||||
from huamenchuang.models.extend import ShareItem
|
||||
|
||||
router = APIRouter(tags=["分享"])
|
||||
|
||||
SHARE_TYPES = ("render", "combine", "quote", "sunny")
|
||||
|
||||
|
||||
class ShareBody(BaseModel):
|
||||
type: str = Field(pattern="^(render|combine|quote|sunny)$")
|
||||
title: str = Field(default="", max_length=128)
|
||||
payload: dict = Field(default_factory=dict, description="render:{image}, combine:{shapes[]}, quote:{items[],total,customer}, sunny:{image,params}")
|
||||
|
||||
|
||||
@router.post("/shares")
|
||||
async def create_share(body: ShareBody, user: CurrentUser, db: DbSession):
|
||||
"""生成分享链接(对齐 contract_share / share-render 语义)。"""
|
||||
item = ShareItem(
|
||||
company_id=user.company_id, token=secrets.token_urlsafe(24),
|
||||
type=body.type, title=body.title,
|
||||
payload=json.dumps(body.payload, ensure_ascii=False), user_id=user.id)
|
||||
db.add(item)
|
||||
await db.flush()
|
||||
return ok(data={"token": item.token, "url": f"/share/s/{item.token}"}, message="分享链接已生成")
|
||||
|
||||
|
||||
@router.get("/shares/{token}")
|
||||
async def get_share(token: str, db: DbSession):
|
||||
"""公开访问分享内容(无需登录,对齐 group-detail-share/:id/:user_id)。"""
|
||||
item = (await db.execute(select(ShareItem).where(ShareItem.token == token))).scalar_one_or_none()
|
||||
if item is None:
|
||||
return fail("分享不存在或已失效")
|
||||
return ok(data={"type": item.type, "title": item.title,
|
||||
"payload": json.loads(item.payload or "{}"),
|
||||
"created_at": str(item.created_at)[:19]})
|
||||
@@ -0,0 +1,60 @@
|
||||
"""阳光房设计路由(对齐目标站 sunnyhouse:平顶/人字顶参数化设计,出 2D 图 + AI 效果图)。"""
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
|
||||
from huamenchuang.core.deps import CurrentUser, DbSession
|
||||
from huamenchuang.core.response import fail, ok
|
||||
from huamenchuang.models.extend import SunroomDesign
|
||||
|
||||
router = APIRouter(prefix="/sunroom", tags=["阳光房"])
|
||||
|
||||
|
||||
class SunroomBody(BaseModel):
|
||||
order_id: int | None = None
|
||||
name: str = Field(default="", max_length=64)
|
||||
roof: str = Field(default="flat", pattern="^(flat|gable)$")
|
||||
width_mm: int = Field(default=4000, ge=1500, le=20000)
|
||||
depth_mm: int = Field(default=3000, ge=1500, le=20000)
|
||||
height_mm: int = Field(default=2800, ge=2000, le=6000)
|
||||
bay_mm: int = Field(default=1000, ge=400, le=3000)
|
||||
glass: str = Field(default="双层中空钢化玻璃 5+12A+5", max_length=64)
|
||||
frame_color: str = Field(default="深灰", max_length=16)
|
||||
|
||||
|
||||
def _to_out(d: SunroomDesign) -> dict:
|
||||
return {"id": d.id, "order_id": d.order_id, "name": d.name, "roof": d.roof,
|
||||
"width_mm": d.width_mm, "depth_mm": d.depth_mm, "height_mm": d.height_mm,
|
||||
"bay_mm": d.bay_mm, "params": json.loads(d.params or "{}"),
|
||||
"created_at": str(d.created_at)[:19]}
|
||||
|
||||
|
||||
@router.get("/designs")
|
||||
async def list_designs(user: CurrentUser, db: DbSession):
|
||||
rows = list((await db.execute(select(SunroomDesign).where(
|
||||
SunroomDesign.company_id == user.company_id).order_by(SunroomDesign.id.desc()))).scalars())
|
||||
return ok(data=[_to_out(r) for r in rows])
|
||||
|
||||
|
||||
@router.post("/designs")
|
||||
async def save_design(body: SunroomBody, user: CurrentUser, db: DbSession):
|
||||
d = SunroomDesign(company_id=user.company_id, order_id=body.order_id, name=body.name,
|
||||
roof=body.roof, width_mm=body.width_mm, depth_mm=body.depth_mm,
|
||||
height_mm=body.height_mm, bay_mm=body.bay_mm,
|
||||
params=json.dumps({"glass": body.glass, "frame_color": body.frame_color},
|
||||
ensure_ascii=False),
|
||||
user_id=user.id)
|
||||
db.add(d)
|
||||
await db.flush()
|
||||
return ok(data=_to_out(d), message="设计已保存")
|
||||
|
||||
|
||||
@router.delete("/designs/{design_id}")
|
||||
async def delete_design(design_id: int, user: CurrentUser, db: DbSession):
|
||||
d = await db.get(SunroomDesign, design_id)
|
||||
if d is None or d.company_id != user.company_id:
|
||||
return fail("设计不存在")
|
||||
await db.delete(d)
|
||||
return ok(message="已删除")
|
||||
@@ -22,7 +22,9 @@ from huamenchuang.models.extend import (
|
||||
Device,
|
||||
OrderFormat,
|
||||
PayOrder,
|
||||
ShareItem,
|
||||
SmsCode,
|
||||
SunroomDesign,
|
||||
)
|
||||
from huamenchuang.models.order import DEFAULT_PROCESSES, Order, OrderLog, OrderProcess
|
||||
from huamenchuang.models.user import User
|
||||
@@ -49,7 +51,9 @@ __all__ = [
|
||||
"ProductCatelog",
|
||||
"Quotation",
|
||||
"ReceiptAccount",
|
||||
"ShareItem",
|
||||
"SmsCode",
|
||||
"SunroomDesign",
|
||||
"StockMove",
|
||||
"User",
|
||||
"WarehouseItem",
|
||||
|
||||
@@ -84,6 +84,41 @@ class OrderFormat(Base):
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
|
||||
|
||||
|
||||
class ShareItem(Base):
|
||||
"""通用分享项(对齐目标站 share-render/group-detail-share/报价链接:HMAC token 公开访问)。"""
|
||||
|
||||
__tablename__ = "share_items"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
company_id: Mapped[int] = mapped_column(default=1, index=True)
|
||||
token: Mapped[str] = mapped_column(String(48), unique=True, index=True)
|
||||
# render=渲染图 combine=样板组合 quote=报价单 sunny=阳光房设计
|
||||
type: Mapped[str] = mapped_column(String(16), index=True)
|
||||
title: Mapped[str] = mapped_column(String(128), default="")
|
||||
payload: Mapped[str] = mapped_column(Text, default="{}") # 类型化 JSON(图片 dataURL / 组合图纸 / 报价明细)
|
||||
user_id: Mapped[int] = mapped_column(default=0)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
|
||||
|
||||
|
||||
class SunroomDesign(Base):
|
||||
"""阳光房设计(对齐目标站 sunnyhouse 模块:平顶/人字顶参数化 + 效果图)。"""
|
||||
|
||||
__tablename__ = "sunroom_designs"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
company_id: Mapped[int] = mapped_column(default=1, index=True)
|
||||
order_id: Mapped[int | None] = mapped_column(default=None, index=True) # 关联阳光房订单
|
||||
name: Mapped[str] = mapped_column(String(64), default="")
|
||||
roof: Mapped[str] = mapped_column(String(8), default="flat") # flat平顶 / gable人字顶
|
||||
width_mm: Mapped[int] = mapped_column(default=4000) # 宽(X向)
|
||||
depth_mm: Mapped[int] = mapped_column(default=3000) # 进深(Y向)
|
||||
height_mm: Mapped[int] = mapped_column(default=2800) # 柱高/檐高
|
||||
bay_mm: Mapped[int] = mapped_column(default=1000) # 立柱间距
|
||||
params: Mapped[str] = mapped_column(Text, default="{}") # 扩展(玻璃规格/立柱色等)
|
||||
user_id: Mapped[int] = mapped_column(default=0)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
|
||||
|
||||
|
||||
class SmsCode(Base):
|
||||
"""短信验证码(provider 适配层:未配置短信签名时走 mock 通道)。"""
|
||||
|
||||
|
||||
@@ -41,6 +41,12 @@ QUICK_SCENE = {
|
||||
"studio": "studio product shot on a seamless neutral background, softbox lighting, slight floor reflection",
|
||||
}
|
||||
|
||||
# 阳光房场景(对齐目标站 sunnyhouse 平顶/人字顶)
|
||||
SUNROOM_DESC = {
|
||||
"sunroom-flat": "a flat-roof glass sunroom attached to the house, slim {frame_word} aluminum posts and glass beams, clear tempered glass roof panels, furnished with rattan chairs and plants, garden view",
|
||||
"sunroom-gable": "a gable (A-frame pitched) glass sunroom attached to the house, ridge beam with fan-shaped front glass, {frame_word} aluminum structure, clear glass roof, garden view",
|
||||
}
|
||||
|
||||
|
||||
def _sash_kind(sub: dict) -> str | None:
|
||||
t = str(sub.get("type", ""))
|
||||
@@ -134,12 +140,25 @@ def build_prompt(drawing: str | dict, style: str = "modern", time: str = "day",
|
||||
ratio: portrait/landscape/square → 依据窗型宽高比的构图引导。
|
||||
"""
|
||||
info = describe_window(drawing)
|
||||
dd = parse_drawing(drawing)
|
||||
finish, style_word = STYLE_FINISH.get(style, STYLE_FINISH["modern"])
|
||||
light = TIME_LIGHT.get(time, TIME_LIGHT["day"])
|
||||
scene_tpl = SCENE_DESC.get(scene) or QUICK_SCENE.get(scene) or SCENE_DESC["render"]
|
||||
scene_text = scene_tpl.replace("{style_word}", style_word)
|
||||
|
||||
window_desc = info["summary"] or "a modern aluminum window"
|
||||
# 阳光房场景: 窗型描述让位于结构描述
|
||||
frame_word = "anthracite" if style == "modern" else ("bronze" if style == "chinese" else "white")
|
||||
if scene in SUNROOM_DESC:
|
||||
scene_text = SUNROOM_DESC[scene].replace("{frame_word}", frame_word)
|
||||
sr = dd.get("_sunroom") or {}
|
||||
fw = sr.get("width_mm") or info["width_mm"] or 4000
|
||||
fd = sr.get("depth_mm") or 3000
|
||||
fh = sr.get("height_mm") or 2800
|
||||
bays = max(2, round(fw / (sr.get("bay_mm") or 1000)))
|
||||
window_desc = (f"Structure: {scene_text}; footprint {fw}×{fd}mm, eave height {fh}mm, "
|
||||
f"{bays} bays with slim aluminum posts")
|
||||
else:
|
||||
scene_tpl = SCENE_DESC.get(scene) or QUICK_SCENE.get(scene) or SCENE_DESC["render"]
|
||||
scene_text = scene_tpl.replace("{style_word}", style_word)
|
||||
window_desc = info["summary"] or "a modern aluminum window"
|
||||
composition = {
|
||||
"portrait": "vertical composition, window fills most of the frame height",
|
||||
"landscape": "horizontal composition, generous wall context around the window",
|
||||
|
||||
@@ -27,6 +27,7 @@ class TakeoffResult:
|
||||
width_mm: int = 0
|
||||
height_mm: int = 0
|
||||
sash_count: int = 0
|
||||
sash_assign_count: int = 0
|
||||
meters: dict = field(default_factory=dict) # 各型材类别总米数 {frame, mullion, sash}
|
||||
|
||||
|
||||
@@ -106,6 +107,23 @@ def takeoff(dd) -> TakeoffResult:
|
||||
if cols:
|
||||
result.bars.append(BarItem("dec_bar", round(bb[3] - bb[1]), cols, "格条-竖"))
|
||||
|
||||
# 逐格指定(自研 assigns 扩展): 开扇五金+扇框料 / 副梃
|
||||
for assign in (shape.get("assigns") or []):
|
||||
if not isinstance(assign, dict):
|
||||
continue
|
||||
ri = int(assign.get("ri", -1))
|
||||
if not (0 <= ri < len(region_bbs)):
|
||||
continue
|
||||
bb = region_bbs[ri]
|
||||
w, h = round(bb[2] - bb[0]), round(bb[3] - bb[1])
|
||||
sash = assign.get("sash") or {}
|
||||
if sash.get("kind") and sash["kind"] != "fixed":
|
||||
result.bars.append(BarItem("sash", w, 2, "指定扇-宽"))
|
||||
result.bars.append(BarItem("sash", h, 2, "指定扇-高"))
|
||||
result.sash_assign_count += 1
|
||||
if assign.get("subBar"):
|
||||
result.bars.append(BarItem("subbar", w, 1, "副梃"))
|
||||
|
||||
# 扇框
|
||||
for sub in (shape.get("sm") or []):
|
||||
bd = sub.get("bd") if isinstance(sub, dict) else None
|
||||
@@ -127,7 +145,7 @@ def takeoff(dd) -> TakeoffResult:
|
||||
result.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")
|
||||
)
|
||||
) + result.sash_assign_count
|
||||
return result
|
||||
|
||||
|
||||
@@ -167,7 +185,8 @@ def quote(drawing: str | dict, prices: dict | None = None, scripts: dict | None
|
||||
"""
|
||||
from huamenchuang.services.formula import FormulaError, eval_formula
|
||||
|
||||
p = {"frame": 45, "mullion": 40, "sash": 50, "glass": 120, "hardware": 180, "dec_bar": 12}
|
||||
p = {"frame": 45, "mullion": 40, "sash": 50, "glass": 120, "hardware": 180, "dec_bar": 12,
|
||||
"subbar": 25}
|
||||
if prices:
|
||||
p.update(prices)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user