"""定开项目 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], }