feat: v0.9 样板数据完整性验证/解析加固/报表统计

- 双源确认样板库完整性: search接口与画图编辑器for_ht目录均为254条, 0遗漏
- 全量体检: 6套样板含7个畸形段(缺ps/pe)导致解析报错
  前后端重放/算料统一加固过滤, 254样板0异常/1046窗格
- 报表统计模块(/chart): 近N天订单数金额按日柱图+8项总量+状态分布
- 样板库官方/我的样板双Tab(is_default过滤)
- 使用指南接入帮助文章接口(种子8篇), seed分类树幂等修复
- 全接口回归18/18通过
This commit is contained in:
HHY
2026-09-20 12:27:19 -07:00
parent 3f871d7eeb
commit 453decf2e4
15 changed files with 233 additions and 29 deletions
+53
View File
@@ -0,0 +1,53 @@
"""报表统计路由(对齐原站 /chart 报表)。"""
from datetime import datetime, timedelta
from fastapi import APIRouter
from sqlalchemy import func, select
from huamenchuang.core.deps import CurrentUser, DbSession
from huamenchuang.core.response import ok
from huamenchuang.models.business import AfterSale, MeasureOrder, Quotation
from huamenchuang.models.contract import Contract, Customer
from huamenchuang.models.order import Order
router = APIRouter(prefix="/reports", tags=["报表"])
@router.get("/summary")
async def summary(user: CurrentUser, db: DbSession, days: int = 14):
"""经营汇总:近 N 天订单数/金额按日 + 各实体总数。"""
since = datetime.utcnow() - timedelta(days=days)
cid = user.company_id
# 按日订单数与金额
day_rows = (await db.execute(
select(func.date(Order.created_at), func.count(), func.sum(Order.money))
.where(Order.company_id == cid, Order.created_at >= since)
.group_by(func.date(Order.created_at))
)).all()
by_day = [{"date": str(d), "count": c, "money": float(m or 0)} for d, c, m in day_rows]
async def count(model, extra=None):
conds = [model.company_id == cid]
if extra is not None:
conds.append(extra)
return (await db.execute(select(func.count()).select_from(model).where(*conds))).scalar_one()
totals = {
"orders": await count(Order),
"intentions": await count(Order, Order.status == 0),
"finished": await count(Order, Order.status == 3),
"quotations": await count(Quotation),
"customers": await count(Customer),
"contracts": await count(Contract),
"measures": await count(MeasureOrder),
"after_sales": await count(AfterSale, AfterSale.status != 2),
}
# 订单状态分布
status_rows = (await db.execute(
select(Order.status, func.count()).where(Order.company_id == cid).group_by(Order.status)
)).all()
status_dist = [{"status": s, "count": c} for s, c in status_rows]
return ok(data={"by_day": by_day, "totals": totals, "status_dist": status_dist, "days": days})
+3
View File
@@ -10,6 +10,7 @@ from huamenchuang.api import (
orders,
platform,
quote,
reports,
warehouse,
windoor,
)
@@ -25,3 +26,5 @@ api_router.include_router(quote.router)
api_router.include_router(warehouse.router)
api_router.include_router(business.router)
api_router.include_router(platform.router)
api_router.include_router(reports.router)
api_router.include_router(platform.router)
+3
View File
@@ -32,6 +32,7 @@ async def search_templates(
db: DbSession, user: CurrentUser,
name: str = Query(default="", max_length=64),
catelog_id: int | None = None,
is_default: int | None = Query(default=None, description="1官方样板 0我的样板"),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=50, ge=1, le=200),
):
@@ -41,6 +42,8 @@ async def search_templates(
conds.append(WindoorTemplate.name.like(f"%{name}%"))
if catelog_id is not None:
conds.append(WindoorTemplate.catelog_id == catelog_id)
if is_default is not None:
conds.append(WindoorTemplate.is_default == is_default)
stmt = (select(WindoorTemplate).where(*conds)
.order_by(WindoorTemplate.id).offset((page - 1) * page_size).limit(page_size))
items = list((await db.execute(stmt)).scalars())
+15 -2
View File
@@ -90,13 +90,26 @@ def _split_poly(region: list[dict], axis: str, pos: float) -> tuple[list[dict],
return to_segs(poly_a), to_segs(poly_b)
def _clean_region(region: list) -> list[dict]:
"""过滤畸形段(缺 ps/pe 或类型不对),保证重放/渲染不抛错。"""
out = []
for s in region:
if not isinstance(s, dict):
continue
ps, pe = s.get("ps"), s.get("pe")
if isinstance(ps, dict) and isinstance(pe, dict) and "x" in ps and "y" in ps and "x" in pe and "y" in pe:
out.append({"ps": dict(ps), "pe": dict(pe)})
return out
def replay_shape(shape: dict) -> list[list[dict]]:
"""分割树重放: 基底多边形 + mm.sp[](polyId.idx 指向作用区域) → 最终窗格列表。
与前端 replayShape 同语义: idx 越界取 0; 退化/非凸跳过。
与前端 replayShape 同语义: idx 越界取 0; 退化/非凸跳过; 畸形段过滤。
"""
base = shape.get("polygon", {}).get("polygon", []) or []
regions = [[{"ps": dict(s["ps"]), "pe": dict(s["pe"])} for s in r] for r in base]
regions = [_clean_region(r) for r in base]
regions = [r for r in regions if r]
if not regions:
return regions
for sp in (shape.get("mm") or {}).get("sp", []) or []:
@@ -0,0 +1,12 @@
"""种子文章(帮助中心内容,自撰)。"""
ARTICLES = [
("常见问题解答", "如何更改标线尺寸?", "在画布编辑模式中,直接修改顶部 W/H 输入框,或拖拽外框右缘/下缘/右下角手柄;所有尺寸标注会自动跟随几何更新。"),
("常见问题解答", "如何绘制横竖中梃?", "进入编辑模式后点击「+竖梃」或「+横梃」,再点击目标玻璃区域即可完成分割;点击已有中梃可选中并拖动调整位置,「删除梃」可移除。"),
("常见问题解答", "如何把样板改成自己的?", "样板库 → 点击样板 → 编辑分格/尺寸 → 「另存为我的样板」,原图板不受影响。"),
("常见问题解答", "报价是如何计算的?", "报价 = 外框 + 中梃(按实际跨度)+ 扇框 + 玻璃面积 + 五金(按扇数),价格取自产品管理中的价格本。"),
("功能教程", "订单全流程", "合同管理创建意向单 → 转正式单 → 下订单 → 订单详情中推进生产进度(下料/组装/玻璃/打胶/五金/质检/完工)→ 打印中心输出生产单。"),
("功能教程", "优化下料使用", "录入订单的型材需求与仓库原料,系统按首递减算法输出切割组合、余料与利用率;也可在「下料方案」按合同一键汇总。"),
("功能教程", "3D 展示与效果图", "「效果图渲染」可在场景中预览窗型并调整墙面/玻璃颜色;「3D 展示」提供可旋转的立体视图。"),
("功能教程", "分享订单给客户", "订单管理 → 「分享」→ 复制链接发给客户,客户免登录即可查看订单摘要与图纸。"),
]
+13 -2
View File
@@ -27,9 +27,9 @@ async def seed() -> None:
# 2) 分类树(来自 default_windoor_catelog.json,取目标站原始 id 便于对照)
cat_file = DATA / "api-samples" / "default_windoor_catelog.json"
if cat_file.exists():
from sqlalchemy import select as _sel
cats = json.load(open(cat_file, encoding="utf-8"))["data"]
existing = {c.id for c in (await db.execute(
__import__("sqlalchemy").select(WindoorCatelog.id))).scalars()}
existing = set((await db.execute(_sel(WindoorCatelog.id))).scalars())
for top in cats:
if top["id"] not in existing:
db.add(WindoorCatelog(id=top["id"], name=top["name"], fid=0, company_id=1))
@@ -71,6 +71,17 @@ async def seed() -> None:
await db.commit()
print("种子数据完成")
# 4) 帮助文章(幂等:按标题去重)
async with SessionLocal() as db:
from huamenchuang.models.extend import Article
from huamenchuang.scripts.article_seed_data import ARTICLES
existing = {t for (t,) in (await db.execute(select(Article.title))).all()}
for i, (cat, title, content) in enumerate(ARTICLES):
if title not in existing:
db.add(Article(title=title, category=cat, content=content, sort=i))
await db.commit()
print("帮助文章就绪")
if __name__ == "__main__":
asyncio.run(seed())