feat: P1批次2 报价/产品域(型材系列管理/副梃/成本口径毛利/报价分享)

This commit is contained in:
HHY
2026-09-25 06:24:06 -07:00
parent 76fd0ced14
commit d05a91b7bc
5 changed files with 234 additions and 39 deletions
+90 -7
View File
@@ -1,4 +1,4 @@
"""业务扩展路由:产品价格本、报价单、售后、量尺、阳光房订单过滤。"""
"""业务扩展路由:产品价格本、产品分类(型材系列)、报价单、售后、量尺、阳光房订单过滤。"""
import datetime
from fastapi import APIRouter
@@ -8,7 +8,7 @@ from sqlalchemy import func, or_, select
from huamenchuang.core.database import SessionLocal
from huamenchuang.core.deps import CurrentUser, DbSession
from huamenchuang.core.response import fail, ok, paginated
from huamenchuang.models.business import AfterSale, MeasureOrder, Product, Quotation
from huamenchuang.models.business import AfterSale, MeasureOrder, Product, ProductCatelog, Quotation
from huamenchuang.models.order import Order
router = APIRouter(tags=["业务扩展"])
@@ -18,21 +18,77 @@ router = APIRouter(tags=["业务扩展"])
class ProductBody(BaseModel):
name: str = Field(min_length=1, max_length=128)
material: str = Field(default="profile", pattern="^(profile|glass|hardware|addon)$")
material: str = Field(default="profile", pattern="^(profile|glass|hardware|addon|subbar)$")
spec: str = ""
unit: str = "米"
price: float = 0
cost: float = 0
note: str = ""
enabled: int = 1
catelog_id: int | None = None
@router.get("/product-catelogs")
async def list_catelogs(user: CurrentUser, db: DbSession):
"""产品分类树(型材系列管理,对齐目标站 product-catelog/script 系列)。"""
rows = list((await db.execute(select(ProductCatelog).where(
ProductCatelog.company_id == user.company_id).order_by(ProductCatelog.id))).scalars())
nodes = [{"id": r.id, "name": r.name, "fid": r.fid} for r in rows]
# 平铺 → 树
by_fid: dict[int, list] = {}
for n in nodes:
by_fid.setdefault(n["fid"], []).append({**n, "children": []})
def build(fid: int):
for n in by_fid.get(fid, []):
n["children"] = build(n["id"])
return by_fid.get(fid, [])
return ok(data=build(0))
class CatelogBody(BaseModel):
name: str = Field(min_length=1, max_length=64)
fid: int = 0
@router.post("/product-catelogs")
async def create_catelog(body: CatelogBody, user: CurrentUser, db: DbSession):
c = ProductCatelog(company_id=user.company_id, name=body.name, fid=body.fid)
db.add(c)
await db.flush()
return ok(data={"id": c.id, "name": c.name}, message="系列已创建")
@router.put("/product-catelogs/{cid}")
async def update_catelog(cid: int, body: CatelogBody, user: CurrentUser, db: DbSession):
c = await db.get(ProductCatelog, cid)
if c is None or c.company_id != user.company_id:
return fail("系列不存在")
c.name = body.name
c.fid = body.fid
return ok(message="系列已更新")
@router.delete("/product-catelogs/{cid}")
async def delete_catelog(cid: int, user: CurrentUser, db: DbSession):
c = await db.get(ProductCatelog, cid)
if c is None or c.company_id != user.company_id:
return fail("系列不存在")
used = (await db.execute(select(func.count()).select_from(Product).where(
Product.catelog_id == cid))).scalar_one()
if used:
return fail(f"该系列下还有 {used} 个产品,请先移出")
await db.delete(c)
return ok(message="系列已删除")
@router.get("/products")
async def list_products(user: CurrentUser, db: DbSession, page: int = 1, page_size: int = 15,
keyword: str = "", material: str | None = None):
keyword: str = "", material: str | None = None, catelog_id: int | None = None):
conds = [Product.company_id == user.company_id]
if material:
conds.append(Product.material == material)
if catelog_id:
conds.append(Product.catelog_id == catelog_id)
if kw := keyword.strip():
like = f"%{kw}%"
conds.append(or_(Product.name.like(like), Product.spec.like(like)))
@@ -44,7 +100,7 @@ async def list_products(user: CurrentUser, db: DbSession, page: int = 1, page_si
return ok(data=paginated([{
"id": p.id, "name": p.name, "material": p.material, "spec": p.spec, "unit": p.unit,
"price": float(p.price), "cost": float(p.cost), "enabled": p.enabled,
"note": p.note,
"catelog_id": p.catelog_id, "note": p.note,
} for p in items], page, page_size, total))
@@ -105,21 +161,47 @@ async def _next_quote_no(db, company_id: int) -> str:
def _quote_out(q: Quotation) -> dict:
"""报价输出:附成本口径(quote_by_cost 语义)——items 内 _cost 逐行汇总。"""
import json as _json
try:
items = _json.loads(q.items or "[]")
except Exception:
items = []
cost_total = round(sum(float(i.get("_cost") or 0) for i in items), 2)
return {
"id": q.id, "quote_no": q.quote_no, "customer_id": q.customer_id,
"customer_name": q.customer_name, "contract_id": q.contract_id,
"template_id": q.template_id, "glass_area": float(q.glass_area),
"width_mm": q.width_mm, "height_mm": q.height_mm, "total": float(q.total),
"cost_total": cost_total,
"profit": round(float(q.total) - cost_total, 2),
"status": q.status, "note": q.note, "created_at": str(q.created_at),
}
async def _apply_cost(db, company_id: int, items: list[dict]) -> list[dict]:
"""成本口径:按产品名匹配价格本成本,写入每行 _cost(quote_by_cost 语义)。"""
names = [str(i.get("name") or "").strip() for i in items]
names = [n for n in names if n]
cost_map: dict[str, float] = {}
if names:
rows = (await db.execute(select(Product.name, Product.cost).where(
Product.company_id == company_id, Product.name.in_(names)))).all()
cost_map = {n: float(c or 0) for n, c in rows}
for i in items:
name = str(i.get("name") or "").strip()
qty = float(i.get("qty") or 0)
i["_cost"] = round(cost_map.get(name, float(i.get("cost") or 0)) * qty, 2)
return items
@router.post("/quotations")
async def create_quotation(body: QuotationBody, user: CurrentUser, db: DbSession):
items = await _apply_cost(db, user.company_id, body.items)
q = Quotation(
company_id=user.company_id, user_id=user.id,
quote_no=await _next_quote_no(db, user.company_id),
items=__import__("json").dumps(body.items, ensure_ascii=False),
items=__import__("json").dumps(items, ensure_ascii=False),
**{k: v for k, v in body.model_dump().items() if k != "items"},
)
db.add(q)
@@ -163,7 +245,8 @@ async def update_quotation(qid: int, body: QuotationBody, user: CurrentUser, db:
return fail("报价单不存在")
for k, v in body.model_dump().items():
if k == "items":
q.items = __import__("json").dumps(v, ensure_ascii=False)
q.items = __import__("json").dumps(
await _apply_cost(db, user.company_id, v), ensure_ascii=False)
else:
setattr(q, k, v)
return ok(data=_quote_out(q), message="更新成功")