unis_manager/app/routers/custom.py

611 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""定制交付项目 API(独立于通用项目体系)。
关注:商务阶段流转 与 按进度确认收入。
"""
from __future__ import annotations
import json
from collections import defaultdict
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import func
from sqlalchemy.orm import Session
from .. import ai_parser
from ..constants import (
CUSTOM_PIPELINE,
CUSTOM_STAGES,
CUSTOM_STAGE_MAP,
CUSTOM_STAGE_ORDER,
CUSTOM_STEP_AGING_DAYS,
custom_revenue_ratio,
normalize_region,
)
from ..db import get_db
from ..models import CustomProject, CustomUpdate, ImportLog, Period, set_people
from .board import resolve_period_ids
router = APIRouter()
class CustomProjectIn(BaseModel):
name: str
customer: str | None = None
region: str | None = None
amount: float | None = None
stage: str = "signed"
progress: float = 0
manual_ratio: float | None = None
revenue_amount: float | None = None
owner: str | None = None # 交付负责人(定制交付为单人,多负责人只用于重点项目)
members: list[str] | None = None # 团队成员
entry_date: str | None = None
sign_date: str | None = None
deliver_date: str | None = None
accept_date: str | None = None
revenue_date: str | None = None
invoice_amount: float | None = None
received_amount: float | None = None
remark: str | None = None
is_key: bool = False
class CustomParseIn(BaseModel):
custom_project_id: int | None = None
project_name: str | None = None
period_id: int | None = None
raw_text: str
use_ai: bool = True
class CustomCommitIn(BaseModel):
custom_project_id: int | None = None
project_name: str | None = None
period_id: int
raw_text: str
parsed: dict
mode: str = "ai"
model: str | None = None
def _ratio(cp: CustomProject) -> float:
return custom_revenue_ratio(cp.stage, cp.progress, cp.manual_ratio)
# 环节日期字段 → 该环节对应的阶段
_STEP_DATE_STAGE = {field: stage for _k, _n, field, stage, _c in CUSTOM_PIPELINE}
def _advance_stage(cp: CustomProject, payload: dict):
"""补录了某个环节的日期,就把商务阶段推进到那一环(只进不退,暂停/关闭不动)。"""
if cp.stage in ("paused", "closed"):
return
target = cp.stage
for field, stage in _STEP_DATE_STAGE.items():
if field in payload and str(payload.get(field) or "").strip():
if CUSTOM_STAGE_ORDER.get(stage, 0) > CUSTOM_STAGE_ORDER.get(target, 0):
target = stage
cp.stage = target
def _entry_key(cp: CustomProject) -> str:
"""入库时间(排序用)。未标注的用创建日期兜底。"""
if cp.entry_date:
return cp.entry_date
return cp.created_at.strftime("%Y-%m-%d") if cp.created_at else ""
def _sort_by_entry(projects):
"""按入库时间倒序(新的在前),两者都取不到的排在最后。"""
return sorted(projects, key=lambda c: (_entry_key(c), c.id), reverse=True)
def _unique_name(db: Session, name: str, exclude_id: int | None = None):
"""同名会让录入按名称匹配时张冠李戴,禁止重名。"""
q = db.query(CustomProject).filter(CustomProject.name == name)
if exclude_id is not None:
q = q.filter(CustomProject.id != exclude_id)
if q.first():
raise HTTPException(400, f"已存在同名定制项目「{name}」,请换个名称")
# ---------------------------------------------------------------- 元信息
@router.get("/custom/meta")
def custom_meta(db: Session = Depends(get_db)):
from ..constants import ALL_REGIONS, REGION_GROUPS
used = [
r[0]
for r in db.query(CustomProject.region)
.filter(CustomProject.region.isnot(None))
.distinct()
.all()
if r[0]
]
order = {r: i for i, r in enumerate(ALL_REGIONS)}
return {
"stages": [
{"code": c, "name": n, "color": col, "base_ratio": round(r * 100, 1)}
for c, n, col, r in CUSTOM_STAGES
],
# 数据中实际用到的区域(按标准顺序排列),用于列表筛选
"regions": sorted(used, key=lambda x: order.get(x, 999)),
# 标准省级行政区全集(分大区),用于编辑时的下拉
"region_groups": [{"group": g, "regions": lst} for g, lst in REGION_GROUPS],
# 全过程流水线(入库 → 签单 → 交付 → 验收 → 计收)
"pipeline": [
{"code": k, "name": n, "date_field": f, "stage": st, "color": col}
for k, n, f, st, col in CUSTOM_PIPELINE
],
"aging_days": CUSTOM_STEP_AGING_DAYS,
}
# ---------------------------------------------------------------- 列表
@router.get("/custom/projects")
def list_custom_projects(
stage: str | None = None,
region: str | None = None,
keyword: str | None = None,
archived: str = Query("false", pattern="^(true|false|all)$"),
key_only: bool = False,
owner: str | None = None,
db: Session = Depends(get_db),
):
q = db.query(CustomProject)
if archived == "false":
q = q.filter(CustomProject.archived.is_(False))
elif archived == "true":
q = q.filter(CustomProject.archived.is_(True))
if stage:
q = q.filter(CustomProject.stage == stage)
if region:
q = q.filter(CustomProject.region == region)
if keyword:
q = q.filter(CustomProject.name.contains(keyword))
if key_only:
q = q.filter(CustomProject.is_key.is_(True))
if owner:
q = q.filter(CustomProject.owner == owner)
return [c.to_dict() for c in _sort_by_entry(q.all())]
@router.post("/custom/projects")
def create_custom_project(payload: CustomProjectIn, db: Session = Depends(get_db)):
data = payload.model_dump(exclude={"members"})
name = (data.get("name") or "").strip()
if not name:
raise HTTPException(400, "项目名称不能为空")
data["name"] = name
data["region"] = normalize_region(data.get("region"))
if not data.get("entry_date"):
data["entry_date"] = date.today().isoformat()
_unique_name(db, name)
cp = CustomProject(**data, source="manual")
set_people(cp, None, payload.members)
db.add(cp)
db.commit()
db.refresh(cp)
return cp.to_dict()
@router.get("/custom/projects/{cid}")
def get_custom_project(cid: int, db: Session = Depends(get_db)):
cp = db.get(CustomProject, cid)
if not cp:
raise HTTPException(404, "定制项目不存在")
ups = (
db.query(CustomUpdate, Period)
.join(Period, Period.id == CustomUpdate.period_id)
.filter(CustomUpdate.custom_project_id == cid)
.order_by(Period.sort_key)
.all()
)
return {
"project": cp.to_dict(),
"updates": [u.to_dict() for u, _ in ups][::-1],
"timeline": [
{
"period": p.to_dict(),
"stage": u.stage,
"progress": u.progress,
"content": u.content,
"revenue_delta": u.revenue_delta,
"amount_delta": u.amount_delta,
}
for u, p in ups
],
}
@router.patch("/custom/projects/{cid}")
def patch_custom_project(cid: int, payload: dict, db: Session = Depends(get_db)):
cp = db.get(CustomProject, cid)
if not cp:
raise HTTPException(404, "定制项目不存在")
allowed = (
"name", "customer", "region", "amount", "stage", "progress", "manual_ratio",
"revenue_amount", "invoice_amount", "received_amount", "owner",
"entry_date", "sign_date", "deliver_date", "accept_date", "revenue_date",
"remark", "is_key", "archived", "period_id",
)
payload = payload or {}
if "name" in payload:
new_name = str(payload.get("name") or "").strip()
if not new_name:
raise HTTPException(400, "项目名称不能为空")
if new_name != cp.name:
_unique_name(db, new_name, exclude_id=cp.id)
payload["name"] = new_name
if "region" in payload:
payload["region"] = normalize_region(payload.get("region"))
for k, v in payload.items():
if k in allowed:
setattr(cp, k, v)
# 团队成员:名单型字段,整体替换(不传即不改);负责人只有单人,走上面的 owner
set_people(cp, None, payload.get("members"))
_advance_stage(cp, payload)
db.commit()
db.refresh(cp)
return cp.to_dict()
@router.delete("/custom/projects/{cid}")
def delete_custom_project(cid: int, db: Session = Depends(get_db)):
cp = db.get(CustomProject, cid)
if not cp:
raise HTTPException(404, "定制项目不存在")
name = cp.name
n_upd = len(cp.updates)
db.delete(cp)
db.commit()
return {"ok": True, "name": name, "deleted_updates": n_upd}
# ---------------------------------------------------------------- 阶段看板
@router.get("/custom/board")
def custom_board(
period_id: int | None = None,
year: int | None = None,
month: int | None = None,
region: str | None = None,
keyword: str | None = None,
db: Session = Depends(get_db),
):
period_ids = resolve_period_ids(db, period_id, year, month)
periods = (
db.query(Period).filter(Period.id.in_(period_ids)).order_by(Period.sort_key).all()
if period_ids
else []
)
q = db.query(CustomProject).filter(CustomProject.archived.is_(False))
if region:
q = q.filter(CustomProject.region == region)
if keyword:
q = q.filter(CustomProject.name.contains(keyword))
projects = _sort_by_entry(q.all())
# 本期有记录的项目
ups = (
db.query(CustomUpdate)
.filter(CustomUpdate.period_id.in_(period_ids))
.all()
if period_ids
else []
)
by_project: dict[int, list[CustomUpdate]] = defaultdict(list)
for u in ups:
by_project[u.custom_project_id].append(u)
updated_ids = set(by_project.keys())
columns = []
for code, name, color, _base in CUSTOM_STAGES:
bucket = _sort_by_entry([c for c in projects if c.stage == code])
columns.append(
{
"code": code,
"name": name,
"color": color,
"count": len(bucket),
"amount": round(sum(c.amount or 0 for c in bucket), 2),
"projects": [c.to_dict() for c in bucket],
"updated_count": sum(1 for c in bucket if c.id in updated_ids),
}
)
return {
"period_ids": period_ids,
"periods": [p.to_dict() for p in periods],
"columns": columns,
"updated_ids": sorted(updated_ids),
}
# ---------------------------------------------------------------- 收入统计
@router.get("/custom/stats")
def custom_stats(
period_id: int | None = None,
year: int | None = None,
month: int | None = None,
region: str | None = None,
keyword: str | None = None,
archived: str = Query("false", pattern="^(true|false|all)$"),
db: Session = Depends(get_db),
):
period_ids = resolve_period_ids(db, period_id, year, month)
periods = (
db.query(Period).filter(Period.id.in_(period_ids)).order_by(Period.sort_key).all()
if period_ids
else []
)
scope_label = (
"、".join(p.label for p in periods[:3]) + ("…" if len(periods) > 3 else "")
if periods
else (f"{year}年{month}月" if year and month else "—")
)
pq = db.query(CustomProject)
if archived == "false":
pq = pq.filter(CustomProject.archived.is_(False))
elif archived == "true":
pq = pq.filter(CustomProject.archived.is_(True))
if region:
pq = pq.filter(CustomProject.region == region)
if keyword:
pq = pq.filter(CustomProject.name.contains(keyword))
projects = pq.all()
amount_total = sum(c.amount or 0 for c in projects)
revenue_total = sum(c.revenue_amount or 0 for c in projects)
confirmed_total = sum((c.amount or 0) * _ratio(c) for c in projects)
invoice_total = sum(c.invoice_amount or 0 for c in projects)
received_total = sum(c.received_amount or 0 for c in projects)
# 按阶段
stage_agg: dict[str, dict] = {
c: {"code": c, "name": n, "color": col, "count": 0, "amount": 0.0, "confirmed": 0.0}
for c, n, col, _ in CUSTOM_STAGES
}
for c in projects:
agg = stage_agg.setdefault(
c.stage,
{"code": c.stage, "name": c.stage, "color": "#94a3b8", "count": 0, "amount": 0.0, "confirmed": 0.0},
)
agg["count"] += 1
agg["amount"] += c.amount or 0
agg["confirmed"] += (c.amount or 0) * _ratio(c)
# 按区域
region_agg: dict[str, dict] = defaultdict(lambda: {"count": 0, "amount": 0.0, "confirmed": 0.0, "revenue": 0.0})
for c in projects:
r = region_agg[c.region or "未标注"]
r["count"] += 1
r["amount"] += c.amount or 0
r["confirmed"] += (c.amount or 0) * _ratio(c)
r["revenue"] += c.revenue_amount or 0
by_region = [
{"name": k, **{kk: round(vv, 2) for kk, vv in v.items() if kk != "count"}, "count": v["count"]}
for k, v in sorted(region_agg.items(), key=lambda x: -x[1]["amount"])
]
# 各周期:更新项目数 / 新增计收
trend_rows = (
db.query(
Period.id, Period.label, Period.sort_key, Period.year, Period.month, Period.week,
func.count(CustomUpdate.id),
)
.outerjoin(CustomUpdate, CustomUpdate.period_id == Period.id)
.group_by(Period.id)
.order_by(Period.sort_key)
.all()
)
trend = [
{
"period_id": r[0], "label": r[1], "sort_key": r[2], "year": r[3],
"month": r[4], "week": r[5], "update_count": r[6],
}
for r in trend_rows
]
ups_in_scope = (
db.query(CustomUpdate).filter(CustomUpdate.period_id.in_(period_ids)).all() if period_ids else []
)
period_revenue = sum(u.revenue_delta or 0 for u in ups_in_scope)
period_amount = sum(u.amount_delta or 0 for u in ups_in_scope)
top = sorted(projects, key=lambda c: -(c.amount or 0))[:12]
recently_stage_changed = sorted(
[c for c in projects if c.period_id in period_ids],
key=lambda c: CUSTOM_STAGE_ORDER.get(c.stage, 99),
)[:10]
return {
"scope_label": scope_label,
"period_ids": period_ids,
"kpi": {
"project_count": len(projects),
"key_count": sum(1 for c in projects if c.is_key),
"amount_total": round(amount_total, 2),
"revenue_total": round(revenue_total, 2),
"confirmed_total": round(confirmed_total, 2),
"pending_total": round(max(0.0, confirmed_total - revenue_total), 2),
"progress_rate": round(confirmed_total / amount_total * 100, 1) if amount_total else 0,
"revenue_rate": round(revenue_total / amount_total * 100, 1) if amount_total else 0,
"invoice_total": round(invoice_total, 2),
"received_total": round(received_total, 2),
"pending_invoice": round(max(0.0, amount_total - invoice_total), 2),
"pending_received": round(max(0.0, amount_total - received_total), 2),
"received_rate": round(received_total / amount_total * 100, 1) if amount_total else 0,
"period_revenue": round(period_revenue, 2),
"period_amount": round(period_amount, 2),
"period_updated": len({u.custom_project_id for u in ups_in_scope}),
},
"by_stage": [stage_agg[c] for c, _, _, _ in CUSTOM_STAGES if stage_agg[c]["count"]],
"by_region": by_region,
"trend": trend[-14:],
"top_projects": [c.to_dict() for c in top],
"recent_changes": [c.to_dict() for c in recently_stage_changed],
"recent_updates": [u.to_dict() for u in sorted(ups_in_scope, key=lambda x: -x.id)[:10]],
}
# ---------------------------------------------------------------- 执行记录
@router.post("/custom/updates")
def create_custom_update(payload: dict, db: Session = Depends(get_db)):
cp = db.get(CustomProject, payload.get("custom_project_id"))
if not cp:
raise HTTPException(404, "定制项目不存在")
u = CustomUpdate(
custom_project_id=payload["custom_project_id"],
period_id=payload["period_id"],
stage=payload.get("stage") or cp.stage,
content=payload.get("content"),
summary=payload.get("summary"),
progress=payload.get("progress"),
amount_delta=payload.get("amount_delta"),
revenue_delta=payload.get("revenue_delta"),
risk=payload.get("risk"),
next_step=payload.get("next_step"),
owner=payload.get("owner"),
source=payload.get("source", "manual"),
raw_text=payload.get("raw_text"),
model=payload.get("model"),
)
db.add(u)
if payload.get("progress") is not None:
cp.progress = float(payload["progress"])
if payload.get("stage"):
cp.stage = payload["stage"]
if payload.get("revenue_delta"):
cp.revenue_amount = (cp.revenue_amount or 0) + float(payload["revenue_delta"])
cp.period_id = payload["period_id"]
db.commit()
db.refresh(u)
return u.to_dict()
@router.delete("/custom/updates/{uid}")
def delete_custom_update(uid: int, db: Session = Depends(get_db)):
u = db.get(CustomUpdate, uid)
if not u:
raise HTTPException(404, "记录不存在")
db.delete(u)
db.commit()
return {"ok": True}
# ---------------------------------------------------------------- AI 录入
@router.post("/custom/ai/parse")
def custom_ai_parse(payload: CustomParseIn, db: Session = Depends(get_db)):
cp = db.get(CustomProject, payload.custom_project_id) if payload.custom_project_id else None
if payload.custom_project_id and not cp:
raise HTTPException(404, "定制项目不存在")
period = db.get(Period, payload.period_id) if payload.period_id else None
parsed, mode, error = ai_parser.parse_custom(
payload.raw_text,
cp.name if cp else (payload.project_name or ""),
period.label if period else "",
cp.stage if cp else "",
db,
use_ai=payload.use_ai,
)
warning = parsed.pop("_warning", None)
return {
"mode": mode,
"error": error,
"warning": warning,
"parsed": parsed,
"project": cp.to_dict() if cp else None,
"period": period.to_dict() if period else None,
}
@router.post("/custom/ai/commit")
def custom_ai_commit(payload: CustomCommitIn, db: Session = Depends(get_db)):
cp = db.get(CustomProject, payload.custom_project_id) if payload.custom_project_id else None
if not cp:
name = (payload.project_name or (payload.parsed or {}).get("name") or "").strip()
if not name:
raise HTTPException(400, "缺少项目信息")
cp = db.query(CustomProject).filter(CustomProject.name == name).one_or_none()
if not cp:
cp = CustomProject(name=name, stage="signed", source="ai")
db.add(cp)
db.flush()
period = db.get(Period, payload.period_id)
if not period:
raise HTTPException(404, "周期不存在")
p = payload.parsed or {}
created = []
items = p.get("updates") or []
if not items:
items = [
{
"content": payload.raw_text,
"progress": p.get("progress"),
"stage": p.get("stage") or cp.stage,
"revenue_delta": p.get("revenue_delta"),
}
]
risks = p.get("risks") or []
nexts = p.get("next_steps") or []
for idx, it in enumerate(items):
u = CustomUpdate(
custom_project_id=cp.id,
period_id=period.id,
stage=it.get("stage") or p.get("stage") or cp.stage,
content=it.get("content") or "",
summary=p.get("summary") if idx == 0 else None,
progress=it.get("progress") if it.get("progress") is not None else p.get("progress"),
revenue_delta=it.get("revenue_delta"),
amount_delta=it.get("amount_delta"),
risk=(risks[0] if idx == 0 and risks else None),
next_step=(nexts[0] if idx == 0 and nexts else None),
source="ai" if payload.mode == "ai" else "manual",
raw_text=payload.raw_text,
model=payload.model,
)
db.add(u)
created.append(u)
# 回写项目主档
if p.get("stage"):
cp.stage = p["stage"]
if p.get("progress") is not None:
cp.progress = float(p["progress"])
if p.get("amount") is not None:
cp.amount = float(p["amount"])
if p.get("revenue_amount") is not None:
cp.revenue_amount = float(p["revenue_amount"])
elif p.get("revenue_delta"):
cp.revenue_amount = (cp.revenue_amount or 0) + float(p["revenue_delta"])
if p.get("summary"):
cp.remark = p["summary"]
cp.period_id = period.id
db.add(
ImportLog(
project_id=None,
period_id=period.id,
raw_input=payload.raw_text,
parsed_json=json.dumps({"custom_project_id": cp.id, **p}, ensure_ascii=False),
mode=payload.mode,
model=payload.model,
)
)
db.commit()
return {
"ok": True,
"project": cp.to_dict(),
"created": [u.to_dict() for u in created],
}