feat: P1批次2 报价/产品域(型材系列管理/副梃/成本口径毛利/报价分享)
This commit is contained in:
@@ -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="更新成功")
|
||||
|
||||
Reference in New Issue
Block a user