feat: P1批次4 CRM+仓库域(客户跟进/业务员业绩/余料/领料/采购单)
This commit is contained in:
@@ -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="已删除")
|
||||
|
||||
@@ -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="采购已入库")
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user