798 lines
28 KiB
Python
798 lines
28 KiB
Python
"""定制项目 API(独立于通用项目体系)。
|
||
|
||
关注:商务阶段流转 与 按进度确认收入。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from collections import defaultdict
|
||
from datetime import date, datetime, timedelta
|
||
|
||
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_ANNUAL_TARGETS_DEFAULT,
|
||
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, Setting, 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
|
||
# 市场/销售侧台账字段
|
||
origin: str | None = None
|
||
project_code: str | None = None
|
||
office: str | None = None
|
||
industry: str | None = None
|
||
man_days: float | None = None
|
||
market_owner: str | None = None
|
||
project_status: str | None = None
|
||
accept_plan: str | None = None
|
||
risk_level: str | None = None
|
||
accept_date_real: str | None = None # 验收日期(真实日期,区别于 accept_date 季度文本)
|
||
|
||
|
||
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 _sort_by_update(projects):
|
||
"""按「更新时间」倒序(最近变动的在前);无更新时间的回落到入库时间。"""
|
||
def key(c):
|
||
d = (c.update_date or "").strip()
|
||
return (d if d else "", _entry_key(c), c.id)
|
||
return sorted(projects, key=key, reverse=True)
|
||
|
||
|
||
def _week_range(today: date | None = None) -> tuple[date, date]:
|
||
"""当前自然周(周一~周日)。"""
|
||
t = today or date.today()
|
||
start = t - timedelta(days=t.weekday())
|
||
return start, start + timedelta(days=6)
|
||
|
||
|
||
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}」,请换个名称")
|
||
|
||
|
||
# ---------------------------------------------------------------- 元信息
|
||
# ---------------------------------------------------------------- 年度计收目标
|
||
# 目标值按「年 + 季度」存在 settings 里(一页一套),没配过就用《年度任务.xlsx》的默认值。
|
||
ANNUAL_TARGETS_PREFIX = "custom_annual_targets"
|
||
|
||
|
||
def _annual_targets_key(year: int) -> str:
|
||
return f"{ANNUAL_TARGETS_PREFIX}:{year}"
|
||
|
||
|
||
def read_annual_targets(db: Session, year: int) -> dict[str, float]:
|
||
row = db.get(Setting, _annual_targets_key(year))
|
||
targets = dict(CUSTOM_ANNUAL_TARGETS_DEFAULT)
|
||
if row and row.value:
|
||
try:
|
||
saved = json.loads(row.value)
|
||
except (TypeError, ValueError):
|
||
saved = {}
|
||
for q in (1, 2, 3, 4):
|
||
v = saved.get(str(q))
|
||
if v is not None and v != "":
|
||
try:
|
||
targets[q] = float(v)
|
||
except (TypeError, ValueError):
|
||
pass
|
||
return {str(q): targets[q] for q in (1, 2, 3, 4)}
|
||
|
||
|
||
class AnnualTargetsIn(BaseModel):
|
||
year: int
|
||
targets: dict[str, float | None]
|
||
|
||
|
||
@router.get("/custom/annual-targets")
|
||
def get_annual_targets(year: int | None = None, db: Session = Depends(get_db)):
|
||
y = year or date.today().year
|
||
return {"year": y, "targets": read_annual_targets(db, y)}
|
||
|
||
|
||
@router.put("/custom/annual-targets")
|
||
def put_annual_targets(payload: AnnualTargetsIn, db: Session = Depends(get_db)):
|
||
y = payload.year
|
||
targets = dict(CUSTOM_ANNUAL_TARGETS_DEFAULT)
|
||
for q in (1, 2, 3, 4):
|
||
v = payload.targets.get(str(q))
|
||
if v is None or v == "":
|
||
targets[q] = 0.0
|
||
continue
|
||
try:
|
||
targets[q] = float(v)
|
||
except (TypeError, ValueError):
|
||
raise HTTPException(400, f"Q{q} 目标值不是数字")
|
||
value = json.dumps({str(q): targets[q] for q in (1, 2, 3, 4)}, ensure_ascii=False)
|
||
key = _annual_targets_key(y)
|
||
row = db.get(Setting, key)
|
||
if row:
|
||
row.value = value
|
||
else:
|
||
db.add(Setting(key=key, value=value))
|
||
db.commit()
|
||
return {"year": y, "targets": {str(q): targets[q] for q in (1, 2, 3, 4)}}
|
||
|
||
|
||
@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,
|
||
# 年度计收目标(万元):默认取《年度任务.xlsx》,页面改过则以库里的值为准
|
||
"annual_targets": {"year": date.today().year, "targets": read_annual_targets(db, date.today().year)},
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------- 列表
|
||
@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_update(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("region") and data.get("office"):
|
||
data["region"] = normalize_region(data.get("office"))
|
||
if not data.get("entry_date"):
|
||
data["entry_date"] = date.today().isoformat()
|
||
data["update_date"] = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||
_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",
|
||
# 市场/销售侧台账字段
|
||
"origin", "project_code", "office", "industry", "man_days",
|
||
"market_owner", "project_status", "accept_plan", "risk_level", "accept_date_real",
|
||
)
|
||
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"))
|
||
changed = False
|
||
for k, v in payload.items():
|
||
if k in allowed:
|
||
if getattr(cp, k) != v:
|
||
changed = True
|
||
setattr(cp, k, v)
|
||
# 团队成员:名单型字段,整体替换(不传即不改);负责人只有单人,走上面的 owner
|
||
set_people(cp, None, payload.get("members"))
|
||
_advance_stage(cp, payload)
|
||
# 「更新时间」= 本次提交时间:台账字段有变化时刷新,便于按周查看最新改动
|
||
if changed:
|
||
cp.update_date = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||
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}
|
||
|
||
|
||
# ---------------------------------------------------------------- 按周删除
|
||
def _week_filtered(db: Session):
|
||
"""本周(周一~周日)有「更新时间」的定制项目。"""
|
||
start, end = _week_range()
|
||
rows = (
|
||
db.query(CustomProject)
|
||
.filter(
|
||
CustomProject.update_date.isnot(None),
|
||
CustomProject.update_date >= start.isoformat(),
|
||
CustomProject.update_date <= end.isoformat(),
|
||
)
|
||
.all()
|
||
)
|
||
return start, end, rows
|
||
|
||
|
||
@router.get("/custom/weekly-preview")
|
||
def weekly_preview(db: Session = Depends(get_db)):
|
||
"""本周有更新的项目清单(供「删除本周更新」二次确认)。"""
|
||
start, end, rows = _week_filtered(db)
|
||
return {
|
||
"start": start.isoformat(),
|
||
"end": end.isoformat(),
|
||
"count": len(rows),
|
||
"amount": round(sum(r.amount or 0 for r in rows), 2),
|
||
"names": [r.name for r in rows[:60]],
|
||
}
|
||
|
||
|
||
@router.post("/custom/purge-week")
|
||
def purge_week(db: Session = Depends(get_db)):
|
||
"""删除本周(周一~周日)更新的定制项目,连带其记录。破坏性操作,前端须二次确认。"""
|
||
start, end, rows = _week_filtered(db)
|
||
names = [r.name for r in rows]
|
||
for r in rows:
|
||
db.delete(r) # custom_updates 走 relationship cascade="all, delete-orphan"
|
||
db.commit()
|
||
return {
|
||
"deleted": len(names),
|
||
"start": start.isoformat(),
|
||
"end": end.isoformat(),
|
||
"names": names,
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------- 阶段看板
|
||
@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.patch("/custom/updates/{uid}")
|
||
def patch_custom_update(uid: int, payload: dict, db: Session = Depends(get_db)):
|
||
"""修改单条定制周期记录。
|
||
|
||
金额/计收是「本期新增量」,改了要按差额回补项目累计值;进度与阶段直接覆盖项目档案。
|
||
"""
|
||
u = db.get(CustomUpdate, uid)
|
||
if not u:
|
||
raise HTTPException(404, "记录不存在")
|
||
cp = db.get(CustomProject, u.custom_project_id)
|
||
prev_revenue = u.revenue_delta or 0.0
|
||
for k in ("content", "summary", "risk", "next_step", "owner", "stage"):
|
||
if k in payload:
|
||
setattr(u, k, payload[k] or None)
|
||
for k in ("progress", "amount_delta", "revenue_delta"):
|
||
if k in payload:
|
||
v = payload[k]
|
||
setattr(u, k, None if v in (None, "") else float(v))
|
||
if "period_id" in payload:
|
||
if not payload["period_id"]:
|
||
raise HTTPException(400, "周期不能为空")
|
||
u.period_id = int(payload["period_id"])
|
||
if payload.get("progress") is not None and cp:
|
||
cp.progress = float(payload["progress"])
|
||
if payload.get("stage") and cp:
|
||
cp.stage = payload["stage"]
|
||
# 「本期新增计收」是增量:改了就按差额回补项目累计计收,保持台账对得上
|
||
if cp and "revenue_delta" in payload:
|
||
cp.revenue_amount = max(0.0, (cp.revenue_amount or 0.0) - prev_revenue + (u.revenue_delta or 0.0))
|
||
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, "记录不存在")
|
||
# 「本期新增计收」是加进项目累计值的增量,删记录要一并扣回,否则台账对不上
|
||
cp = db.get(CustomProject, u.custom_project_id)
|
||
if cp and u.revenue_delta:
|
||
cp.revenue_amount = max(0.0, (cp.revenue_amount or 0.0) - float(u.revenue_delta))
|
||
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],
|
||
}
|