feat: P1批次4 CRM+仓库域(客户跟进/业务员业绩/余料/领料/采购单)

This commit is contained in:
HHY
2026-09-25 06:47:20 -07:00
parent fbe21c17b5
commit 543a94609c
12 changed files with 636 additions and 7 deletions
+43
View File
@@ -130,3 +130,46 @@ async def excel_out(user: CurrentUser, db: DbSession):
data, widths=[16, 18, 30, 14, 12, 12])
return Response(content, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": "attachment; filename=customers.xlsx"})
# ---------------- 客户跟进记录(对齐 customer_records) ----------------
class FollowBody(BaseModel):
content: str = Field(min_length=1)
next_date: str | None = None # YYYY-MM-DD
@router.get("/{customer_id}/follows")
async def list_follows(customer_id: int, user: CurrentUser, db: DbSession):
from huamenchuang.models.business import CustomerFollow
rows = list((await db.execute(select(CustomerFollow).where(
CustomerFollow.company_id == user.company_id,
CustomerFollow.customer_id == customer_id)
.order_by(CustomerFollow.id.desc()).limit(100))).scalars())
return ok(data=[{"id": r.id, "content": r.content, "next_date": str(r.next_date) if r.next_date else None,
"created_at": str(r.created_at)[:19]} for r in rows])
@router.post("/{customer_id}/follows")
async def create_follow(customer_id: int, body: FollowBody, user: CurrentUser, db: DbSession):
from datetime import date as _date
from huamenchuang.models.business import CustomerFollow
c = await db.get(Customer, customer_id)
if c is None or c.company_id != user.company_id:
return fail("客户不存在")
r = CustomerFollow(company_id=user.company_id, customer_id=customer_id,
content=body.content, user_id=user.id,
next_date=_date.fromisoformat(body.next_date) if body.next_date else None)
db.add(r)
await db.flush()
return ok(data={"id": r.id}, message="跟进已记录")
@router.delete("/{customer_id}/follows/{fid}")
async def delete_follow(customer_id: int, fid: int, user: CurrentUser, db: DbSession):
from huamenchuang.models.business import CustomerFollow
r = await db.get(CustomerFollow, fid)
if r is None or r.company_id != user.company_id or r.customer_id != customer_id:
return fail("记录不存在")
await db.delete(r)
return ok(message="已删除")
+110
View File
@@ -0,0 +1,110 @@
"""采购单路由(对齐目标站 wareHouse/purchase:缺料生成采购,入库联动库存)。"""
import datetime
import json
from fastapi import APIRouter
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from huamenchuang.core.deps import CurrentUser, DbSession
from huamenchuang.core.response import fail, ok, paginated
from huamenchuang.models.business import PurchaseOrder
router = APIRouter(prefix="/purchases", tags=["采购单"])
class PurchaseItem(BaseModel):
name: str
spec: str = ""
length: int = 0
qty: int = 1
note: str = ""
class PurchaseBody(BaseModel):
contract_id: int | None = None
note: str = ""
items: list[PurchaseItem] = Field(default_factory=list, description="留空则从合同下料方案缺料清单生成")
async def _next_po_no(db, company_id: int) -> str:
today = datetime.date.today().strftime("%Y%m%d")
stmt = select(func.count(PurchaseOrder.id)).where(
PurchaseOrder.company_id == company_id,
func.strftime("%Y%m%d", PurchaseOrder.created_at) == today)
n = (await db.execute(stmt)).scalar() or 0
return f"CG{today}{n + 1:03d}"
def _out(p: PurchaseOrder) -> dict:
return {"id": p.id, "po_no": p.po_no, "contract_id": p.contract_id,
"items": json.loads(p.items or "[]"), "status": p.status,
"note": p.note, "created_at": str(p.created_at)[:19]}
@router.post("")
async def create_purchase(body: PurchaseBody, user: CurrentUser, db: DbSession):
"""创建采购单:items 为空时自动读取合同下料方案的缺料清单(shortage)。"""
import json as _json
items = [i.model_dump() for i in body.items]
if not items:
if not body.contract_id:
return fail("请提供采购明细或合同(自动读取缺料清单)")
from huamenchuang.models.extend import CompanyParam
p = (await db.execute(select(CompanyParam).where(
CompanyParam.company_id == user.company_id,
CompanyParam.key == f"cut_solution_{body.contract_id}"))).scalar_one_or_none()
if p is None:
return fail("该合同尚未生成下料方案,无缺料清单")
shortage = _json.loads(p.value).get("shortage") or []
if not shortage:
return fail("该合同下料方案没有缺料项")
items = [{"name": f"{s['label']} 缺料", "spec": f"{s['length']}mm", "length": s["length"],
"qty": s["qty"], "note": "下料缺料自动生成"} for s in shortage]
po = PurchaseOrder(company_id=user.company_id,
po_no=await _next_po_no(db, user.company_id),
contract_id=body.contract_id,
items=json.dumps(items, ensure_ascii=False),
note=body.note, user_id=user.id)
db.add(po)
await db.flush()
return ok(data=_out(po), message="采购单已创建")
@router.get("")
async def list_purchases(user: CurrentUser, db: DbSession, page: int = 1, page_size: int = 15,
status: int | None = None):
conds = [PurchaseOrder.company_id == user.company_id]
if status is not None:
conds.append(PurchaseOrder.status == status)
where = *conds,
total = (await db.execute(select(func.count()).select_from(PurchaseOrder).where(*where))).scalar_one()
rows = (await db.execute(select(PurchaseOrder).where(*where).order_by(PurchaseOrder.id.desc())
.offset((page - 1) * page_size).limit(page_size))).scalars()
return ok(data=paginated([_out(p) for p in rows], page, page_size, total))
@router.post("/{po_id}/receive")
async def receive_purchase(po_id: int, user: CurrentUser, db: DbSession):
"""采购入库联动:items 写入库存(材料入库 + 流水)。"""
po = await db.get(PurchaseOrder, po_id)
if po is None or po.company_id != user.company_id:
return fail("采购单不存在")
if po.status == 1:
return fail("该采购单已入库")
from huamenchuang.models.contract import StockMove, WarehouseItem
for i in json.loads(po.items or "[]"):
if not i.get("name"):
continue
item = WarehouseItem(company_id=user.company_id, name=i["name"],
material="profile", spec=i.get("spec") or "",
length=int(i.get("length") or 0),
count=int(i.get("qty") or 1), unit="根",
note=f"采购入库 {po.po_no}")
db.add(item)
await db.flush()
db.add(StockMove(item_id=item.id, direction=1, count=int(i.get("qty") or 1),
note=f"采购入库 {po.po_no}", user_id=user.id))
po.status = 1
po.received_at = datetime.datetime.utcnow()
return ok(message="采购已入库")
+4
View File
@@ -12,9 +12,11 @@ from huamenchuang.api import (
order_format,
orders,
platform,
purchases,
quote,
recommend,
reports,
salesmen,
script,
shares,
sunroom,
@@ -39,6 +41,8 @@ api_router.include_router(shares.router)
api_router.include_router(recommend.router)
api_router.include_router(sunroom.router)
api_router.include_router(workshop.router)
api_router.include_router(salesmen.router)
api_router.include_router(purchases.router)
api_router.include_router(warehouse.router)
api_router.include_router(business.router)
api_router.include_router(platform.router)
+82
View File
@@ -0,0 +1,82 @@
"""业务员路由:名册 CRUD + 业绩统计(对齐目标站 salesmen / salesmen_statistics)。"""
import datetime
from fastapi import APIRouter
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from huamenchuang.core.deps import CurrentUser, DbSession
from huamenchuang.core.response import fail, ok
from huamenchuang.models.business import Salesman
from huamenchuang.models.contract import Contract, Customer
router = APIRouter(prefix="/salesmen", tags=["业务员"])
class SalesmanBody(BaseModel):
name: str = Field(min_length=1, max_length=64)
phone: str = ""
commission_rate: float = Field(default=0, ge=0, le=100, description="提成比例 %")
note: str = ""
status: int = Field(default=1, ge=0, le=1)
@router.get("")
async def list_salesmen(user: CurrentUser, db: DbSession):
rows = list((await db.execute(select(Salesman).where(
Salesman.company_id == user.company_id).order_by(Salesman.id))).scalars())
return ok(data=[{"id": s.id, "name": s.name, "phone": s.phone,
"commission_rate": float(s.commission_rate), "note": s.note,
"status": s.status} for s in rows])
@router.post("")
async def create_salesman(body: SalesmanBody, user: CurrentUser, db: DbSession):
s = Salesman(company_id=user.company_id, **body.model_dump())
db.add(s)
await db.flush()
return ok(data={"id": s.id}, message="业务员已添加")
@router.put("/{sid}")
async def update_salesman(sid: int, body: SalesmanBody, user: CurrentUser, db: DbSession):
s = await db.get(Salesman, sid)
if s is None or s.company_id != user.company_id:
return fail("业务员不存在")
for k, v in body.model_dump().items():
setattr(s, k, v)
return ok(message="已更新")
@router.delete("/{sid}")
async def delete_salesman(sid: int, user: CurrentUser, db: DbSession):
s = await db.get(Salesman, sid)
if s is None or s.company_id != user.company_id:
return fail("业务员不存在")
await db.delete(s)
return ok(message="已删除")
@router.get("/statistics")
async def statistics(user: CurrentUser, db: DbSession):
"""业务员业绩:名下客户数 / 合同数 / 合同总额 / 预估提成。"""
rows = list((await db.execute(select(Salesman).where(
Salesman.company_id == user.company_id, Salesman.status == 1))).scalars())
# customers.salesmen 存业务员姓名(目标站同款字段语义)
cust = (await db.execute(select(Customer.salesmen, func.count()).where(
Customer.company_id == user.company_id).group_by(Customer.salesmen))).all()
contract = (await db.execute(select(Customer.salesmen, func.count(Contract.id),
func.coalesce(func.sum(Contract.money), 0))
.join(Contract, Contract.customer_id == Customer.id)
.where(Customer.company_id == user.company_id, Contract.status < 3)
.group_by(Customer.salesmen))).all()
cust_map = {n: c for n, c in cust if n}
ct_map = {n: (c, float(m or 0)) for n, c, m in contract if n}
return ok(data=[{
"id": s.id, "name": s.name, "phone": s.phone,
"commission_rate": float(s.commission_rate),
"customer_count": cust_map.get(s.name, 0),
"contract_count": ct_map.get(s.name, (0, 0))[0],
"contract_money": ct_map.get(s.name, (0, 0))[1],
"commission": round(ct_map.get(s.name, (0, 0))[1] * float(s.commission_rate) / 100, 2),
} for s in rows])
+89 -2
View File
@@ -1,7 +1,7 @@
"""仓库库存路由:材料 CRUD + 出入库 + Excel 导入导出。"""
"""仓库库存路由:材料 CRUD + 出入库 + 余料/领料 + Excel 导入导出。"""
from fastapi import APIRouter, UploadFile
from pydantic import BaseModel, Field
from sqlalchemy import desc, select
from sqlalchemy import desc, func, select
from huamenchuang.core.deps import CurrentUser, DbSession
from huamenchuang.core.response import fail, ok, paginated
@@ -69,6 +69,93 @@ async def create_item(body: ItemBody, user: CurrentUser, db: DbSession):
return ok(data={"id": item.id}, message="创建成功")
# ---------------- 余料管理 / 领料(对齐目标站 surplus / pickout) ----------------
class SurplusBody(BaseModel):
contract_id: int | None = Field(default=None, description="留空则读取已保存的最近方案? 用合同方案")
min_length: int = Field(default=300, ge=0, le=3000, description="余料最小长度(低于此视为废料不入库)")
@router.get("/surplus")
async def list_surplus(user: CurrentUser, db: DbSession, page: int = 1, page_size: int = 15):
"""余料列表(note=surplus 的库存项)。"""
conds = [WarehouseItem.company_id == user.company_id, WarehouseItem.note == "surplus"]
total = (await db.execute(select(func.count()).select_from(WarehouseItem).where(*conds))).scalar_one()
items = (await db.execute(select(WarehouseItem).where(*conds).order_by(WarehouseItem.length.desc())
.offset((page - 1) * page_size).limit(page_size))).scalars()
return ok(data=paginated([_out(i) for i in items], page, page_size, total))
@router.post("/surplus-in")
async def surplus_in(body: SurplusBody, user: CurrentUser, db: DbSession):
"""余料入库:读取合同下料方案,把 ≥ 最小长度的余料逐根入库(surplus 语义)。"""
import json as _json
from huamenchuang.models.extend import CompanyParam
if not body.contract_id:
return fail("请指定合同(读取其下料方案余料)")
p = (await db.execute(select(CompanyParam).where(
CompanyParam.company_id == user.company_id,
CompanyParam.key == f"cut_solution_{body.contract_id}"))).scalar_one_or_none()
if p is None:
return fail("该合同尚未生成下料方案")
plans = (_json.loads(p.value).get("result") or {}).get("plans") or []
added = 0
for plan in plans:
if plan.get("waste", 0) >= body.min_length:
item = WarehouseItem(company_id=user.company_id,
name=f"余料-{plan['stock_label']}",
material="profile", length=int(plan["waste"]),
count=1, unit="根", note="surplus")
db.add(item)
await db.flush()
db.add(StockMove(item_id=item.id, direction=1, count=1,
note="余料入库", user_id=user.id))
added += 1
if not added:
return fail("该方案没有达到最小长度的余料")
return ok(data={"added": added}, message=f"余料入库 {added} 根")
class PickoutBody(BaseModel):
contract_id: int
note: str = ""
@router.post("/pickout")
async def pickout(body: PickoutBody, user: CurrentUser, db: DbSession):
"""领料出库:按合同下料方案的领料清单,从库存扣减原料(pickout 语义)。"""
import json as _json
from huamenchuang.models.extend import CompanyParam
p = (await db.execute(select(CompanyParam).where(
CompanyParam.company_id == user.company_id,
CompanyParam.key == f"cut_solution_{body.contract_id}"))).scalar_one_or_none()
if p is None:
return fail("该合同尚未生成下料方案")
preparation = _json.loads(p.value).get("preparation") or []
if not preparation:
return fail("该方案没有领料清单")
picked, shortage = [], []
for prep in preparation:
need = int(prep["bars"])
# 同长度且数量足够的库存扣减
stmt = (select(WarehouseItem).where(
WarehouseItem.company_id == user.company_id,
WarehouseItem.material == "profile",
WarehouseItem.length == prep["stock_length"],
WarehouseItem.count >= need).order_by(WarehouseItem.count.desc()))
item = (await db.execute(stmt)).scalars().first()
if item is None:
shortage.append(f"{prep['stock_label']} 缺 {need} 根")
continue
item.count = float(item.count) - need
db.add(StockMove(item_id=item.id, direction=-1, count=need,
note=f"领料-{body.note or '车间切割'}", user_id=user.id))
picked.append({"item": item.name, "length": prep["stock_length"], "qty": need})
if shortage:
return fail(";".join(shortage) + f"(已领 {len(picked)} 项未提交,请先补库或采购)")
return ok(data={"picked": picked}, message=f"领料完成:{len(picked)} 项出库")
# ---------------- Excel 导入导出 ----------------
@router.get("/excel-out")