unis_manager/app/routers/custom.py

798 lines
28 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, 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],
}