581 lines
25 KiB
Python
581 lines
25 KiB
Python
"""数据模型。
|
||
|
||
层级:Period(周期) -> Project(主要项目/工作项) -> Task(子任务) -> Update(执行情况)
|
||
辅助:Category(分类) / Tag(标记) / LedgerItem(客户项目台账) / Setting(配置)
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from datetime import datetime
|
||
|
||
from sqlalchemy import (
|
||
Boolean,
|
||
Column,
|
||
DateTime,
|
||
Float,
|
||
ForeignKey,
|
||
Integer,
|
||
String,
|
||
Table,
|
||
Text,
|
||
)
|
||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||
|
||
from .db import Base
|
||
|
||
|
||
def now():
|
||
return datetime.now()
|
||
|
||
|
||
project_tags = Table(
|
||
"project_tags",
|
||
Base.metadata,
|
||
Column("project_id", Integer, ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True),
|
||
Column("tag_id", Integer, ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True),
|
||
)
|
||
|
||
|
||
# ------------------------------------------------------------------ 人员名单
|
||
# 多负责人 / 团队成员是「一串人名」,不建关联表:直接塞进一个字符串列,
|
||
# 首尾都带逗号(",张三,李四,"),这样才能用 LIKE '%,张三,%' 精确匹配某个人,
|
||
# 不至于让「张三」命中「张三丰」。读出来 split,写进去 join。
|
||
PEOPLE_SEP = ","
|
||
|
||
|
||
def split_people(value: str | None) -> list[str]:
|
||
"""存 → 名单。"""
|
||
return [x for x in (value or "").split(PEOPLE_SEP) if x]
|
||
|
||
|
||
def dedupe_people(names) -> list[str]:
|
||
"""清洗前端传来的名单:去空白、去重、保持顺序。"""
|
||
out: list[str] = []
|
||
for n in names or []:
|
||
n = (n or "").strip()
|
||
if n and n not in out:
|
||
out.append(n)
|
||
return out
|
||
|
||
|
||
def join_people(names) -> str | None:
|
||
"""名单 → 存(空名单存 NULL)。"""
|
||
out = dedupe_people(names)
|
||
if not out:
|
||
return None
|
||
return f"{PEOPLE_SEP}{PEOPLE_SEP.join(out)}{PEOPLE_SEP}"
|
||
|
||
|
||
def people_like(name: str) -> str:
|
||
"""按人名匹配名单列的 LIKE 模式。"""
|
||
return f"%{PEOPLE_SEP}{(name or '').strip()}{PEOPLE_SEP}%"
|
||
|
||
|
||
def all_owners(owner: str | None, other_owners: str | None) -> list[str]:
|
||
"""主负责人 + 其他负责人,主负责人恒在第一位。"""
|
||
return dedupe_people([owner] + split_people(other_owners))
|
||
|
||
|
||
def set_people(obj, owners=None, members=None):
|
||
"""整体替换(不是增量):owners 传空数组即清空。传 None 表示这次不改。
|
||
|
||
owners 的第一位就是主负责人,写进 obj.owner —— 看板、筛选、导出、
|
||
人力资源库负载全都照旧读 owner,一个字都不用改。
|
||
"""
|
||
if owners is not None:
|
||
names = dedupe_people(owners)
|
||
obj.owner = names[0] if names else None
|
||
obj.other_owners = join_people(names[1:])
|
||
if members is not None:
|
||
obj.members = join_people(members)
|
||
|
||
|
||
class TimestampMixin:
|
||
created_at: Mapped[datetime] = mapped_column(DateTime, default=now, nullable=False)
|
||
updated_at: Mapped[datetime] = mapped_column(
|
||
DateTime, default=now, onupdate=now, nullable=False
|
||
)
|
||
|
||
|
||
class Category(Base, TimestampMixin):
|
||
"""项目分类(如 平台产品 / AI / 定制交付 / 内部运营)。"""
|
||
|
||
__tablename__ = "categories"
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||
name: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||
color: Mapped[str] = mapped_column(String(16), default="#2563eb", nullable=False)
|
||
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||
|
||
projects: Mapped[list["Project"]] = relationship(back_populates="category")
|
||
|
||
def to_dict(self, with_count: bool = False):
|
||
data = {
|
||
"id": self.id,
|
||
"name": self.name,
|
||
"color": self.color,
|
||
"sort_order": self.sort_order,
|
||
}
|
||
if with_count:
|
||
data["project_count"] = len(self.projects)
|
||
return data
|
||
|
||
|
||
class Tag(Base):
|
||
"""自由标记(如 重点 / 需支援 / 待决策)。"""
|
||
|
||
__tablename__ = "tags"
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||
name: Mapped[str] = mapped_column(String(32), unique=True, nullable=False)
|
||
color: Mapped[str] = mapped_column(String(16), default="#6b7280", nullable=False)
|
||
|
||
def to_dict(self):
|
||
return {"id": self.id, "name": self.name, "color": self.color}
|
||
|
||
|
||
class Project(Base, TimestampMixin):
|
||
"""主要项目 / 工作项。"""
|
||
|
||
__tablename__ = "projects"
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||
name: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||
category_id: Mapped[int | None] = mapped_column(
|
||
Integer, ForeignKey("categories.id"), nullable=True
|
||
)
|
||
owner: Mapped[str | None] = mapped_column(String(64), nullable=True) # 主负责人
|
||
other_owners: Mapped[str | None] = mapped_column(String(512), nullable=True) # 其他负责人
|
||
members: Mapped[str | None] = mapped_column(String(512), nullable=True) # 团队成员
|
||
status: Mapped[str] = mapped_column(String(32), default="in_progress", nullable=False)
|
||
priority: Mapped[str] = mapped_column(String(8), default="p2", nullable=False)
|
||
is_key: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) # 重点标记
|
||
progress: Mapped[float] = mapped_column(Float, default=0.0, nullable=False) # 最新进度 %
|
||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||
archived: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||
|
||
category: Mapped[Category | None] = relationship(back_populates="projects")
|
||
tags: Mapped[list[Tag]] = relationship(secondary=project_tags, lazy="selectin")
|
||
tasks: Mapped[list["Task"]] = relationship(
|
||
back_populates="project", cascade="all, delete-orphan"
|
||
)
|
||
updates: Mapped[list["Update"]] = relationship(
|
||
back_populates="project", cascade="all, delete-orphan"
|
||
)
|
||
|
||
def to_dict(self, brief: bool = True):
|
||
data = {
|
||
"id": self.id,
|
||
"name": self.name,
|
||
"category_id": self.category_id,
|
||
"category": self.category.to_dict() if self.category else None,
|
||
"owner": self.owner,
|
||
"owners": all_owners(self.owner, self.other_owners),
|
||
"members": split_people(self.members),
|
||
"status": self.status,
|
||
"priority": self.priority,
|
||
"is_key": self.is_key,
|
||
"progress": round(self.progress or 0, 1),
|
||
"description": self.description,
|
||
"archived": self.archived,
|
||
"sort_order": self.sort_order,
|
||
"tags": [t.to_dict() for t in self.tags],
|
||
"updated_at": self.updated_at.isoformat(timespec="seconds") if self.updated_at else None,
|
||
}
|
||
if not brief:
|
||
data["task_count"] = len(self.tasks)
|
||
data["update_count"] = len(self.updates)
|
||
return data
|
||
|
||
|
||
class Period(Base):
|
||
"""周期(周 / 月)。"""
|
||
|
||
__tablename__ = "periods"
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||
label: Mapped[str] = mapped_column(String(64), nullable=False) # 8月第4周
|
||
kind: Mapped[str] = mapped_column(String(16), default="week", nullable=False) # week|month
|
||
year: Mapped[int] = mapped_column(Integer, nullable=False)
|
||
month: Mapped[int] = mapped_column(Integer, nullable=False)
|
||
week: Mapped[int] = mapped_column(Integer, default=0, nullable=False) # 月视图为 0
|
||
start_date: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||
end_date: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||
# 排序键:year*10000 + month*100 + week
|
||
sort_key: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
||
|
||
updates: Mapped[list["Update"]] = relationship(back_populates="period")
|
||
|
||
def to_dict(self):
|
||
return {
|
||
"id": self.id,
|
||
"label": self.label,
|
||
"kind": self.kind,
|
||
"year": self.year,
|
||
"month": self.month,
|
||
"week": self.week,
|
||
"start_date": self.start_date,
|
||
"end_date": self.end_date,
|
||
"sort_key": self.sort_key,
|
||
"full_label": f"{self.year}年{self.month}月" + (f"第{self.week}周" if self.week else ""),
|
||
}
|
||
|
||
|
||
class Task(Base, TimestampMixin):
|
||
"""项目下的子任务(对应原表『内容说明』)。"""
|
||
|
||
__tablename__ = "tasks"
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||
project_id: Mapped[int] = mapped_column(
|
||
Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
|
||
)
|
||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||
|
||
project: Mapped[Project] = relationship(back_populates="tasks")
|
||
updates: Mapped[list["Update"]] = relationship(back_populates="task")
|
||
|
||
def to_dict(self):
|
||
return {
|
||
"id": self.id,
|
||
"project_id": self.project_id,
|
||
"name": self.name,
|
||
"sort_order": self.sort_order,
|
||
}
|
||
|
||
|
||
class Update(Base, TimestampMixin):
|
||
"""某周期下、某项目(可细化到子任务)的执行情况。"""
|
||
|
||
__tablename__ = "updates"
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||
project_id: Mapped[int] = mapped_column(
|
||
Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
|
||
)
|
||
task_id: Mapped[int | None] = mapped_column(
|
||
Integer, ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
|
||
)
|
||
period_id: Mapped[int] = mapped_column(
|
||
Integer, ForeignKey("periods.id", ondelete="CASCADE"), nullable=False, index=True
|
||
)
|
||
content: Mapped[str | None] = mapped_column(Text, nullable=True) # 执行情况正文
|
||
summary: Mapped[str | None] = mapped_column(Text, nullable=True) # 一句话摘要
|
||
progress: Mapped[float | None] = mapped_column(Float, nullable=True) # 该条进度 %
|
||
status: Mapped[str] = mapped_column(String(32), default="in_progress", nullable=False)
|
||
risk: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||
next_step: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||
owner: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||
source: Mapped[str] = mapped_column(String(16), default="manual", nullable=False) # import|ai|manual
|
||
raw_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||
model: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||
|
||
project: Mapped[Project] = relationship(back_populates="updates")
|
||
task: Mapped[Task | None] = relationship(back_populates="updates")
|
||
period: Mapped[Period] = relationship(back_populates="updates")
|
||
|
||
def to_dict(self, with_project: bool = False):
|
||
data = {
|
||
"id": self.id,
|
||
"project_id": self.project_id,
|
||
"task_id": self.task_id,
|
||
"task_name": self.task.name if self.task else None,
|
||
"period_id": self.period_id,
|
||
"period_label": self.period.label if self.period else None,
|
||
"period_full": self.period.to_dict()["full_label"] if self.period else None,
|
||
"content": self.content,
|
||
"summary": self.summary,
|
||
"progress": self.progress,
|
||
"status": self.status,
|
||
"risk": self.risk,
|
||
"next_step": self.next_step,
|
||
"owner": self.owner,
|
||
"source": self.source,
|
||
"model": self.model,
|
||
"created_at": self.created_at.isoformat(timespec="seconds") if self.created_at else None,
|
||
}
|
||
if with_project and self.project:
|
||
data["project_name"] = self.project.name
|
||
data["project_is_key"] = self.project.is_key
|
||
return data
|
||
|
||
|
||
class WeeklyFocus(Base, TimestampMixin):
|
||
"""每周重点工作:单条录入、按周跟踪的短期任务。
|
||
|
||
与 Project / Update 分开——不用先建项目档案,一条就是一个活儿。
|
||
period_id 是录入周(新建时所在的那一周,语义同 Update.period_id);
|
||
预计完成时间是日历日期(due_date),不参与按周分组;
|
||
是否延期(delayed)由人工开关,更早周次延期未完成的条目结转进当周视图。
|
||
"""
|
||
|
||
__tablename__ = "weekly_focus"
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||
content: Mapped[str] = mapped_column(String(255), nullable=False) # 工作内容
|
||
detail: Mapped[str | None] = mapped_column(Text, nullable=True) # 说明
|
||
period_id: Mapped[int] = mapped_column(
|
||
Integer, ForeignKey("periods.id", ondelete="CASCADE"), nullable=False, index=True
|
||
) # 录入周(新建时所在的这一周,用于按周分组;不是预计完成时间所在的周)
|
||
due_date: Mapped[str | None] = mapped_column(String(32), nullable=True) # 预计完成时间(日历日期 YYYY-MM-DD,只作目标日期看)
|
||
result: Mapped[str | None] = mapped_column(Text, nullable=True) # 结果反馈
|
||
delayed: Mapped[bool | None] = mapped_column(Boolean, nullable=True) # 是否延期(人工开关)
|
||
status: Mapped[str] = mapped_column(String(32), default="not_started", nullable=False)
|
||
archived: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||
|
||
period: Mapped[Period] = relationship()
|
||
|
||
def to_dict(self):
|
||
return {
|
||
"id": self.id,
|
||
"content": self.content,
|
||
"detail": self.detail,
|
||
"period_id": self.period_id,
|
||
"period_label": self.period.label if self.period else None,
|
||
"period_full": self.period.to_dict()["full_label"] if self.period else None,
|
||
"sort_key": self.period.sort_key if self.period else 0,
|
||
"due_date": self.due_date,
|
||
"result": self.result,
|
||
"delayed": bool(self.delayed),
|
||
"status": self.status,
|
||
"archived": self.archived,
|
||
"updated_at": self.updated_at.isoformat(timespec="seconds") if self.updated_at else None,
|
||
}
|
||
|
||
|
||
class LedgerItem(Base, TimestampMixin):
|
||
"""客户项目台账(来自《项目明细表》:区域 / 金额 / 类别 / 状态说明)。"""
|
||
|
||
__tablename__ = "ledger_items"
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||
region: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||
amount: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||
category: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||
status_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||
period_label: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||
period_id: Mapped[int | None] = mapped_column(
|
||
Integer, ForeignKey("periods.id", ondelete="SET NULL"), nullable=True
|
||
)
|
||
|
||
def to_dict(self):
|
||
return {
|
||
"id": self.id,
|
||
"name": self.name,
|
||
"region": self.region,
|
||
"amount": self.amount,
|
||
"category": self.category,
|
||
"status_note": self.status_note,
|
||
"period_label": self.period_label,
|
||
"period_id": self.period_id,
|
||
}
|
||
|
||
|
||
class CustomProject(Base, TimestampMixin):
|
||
"""定制交付项目(与通用研发项目完全分离)。
|
||
|
||
关注:商务阶段流转(签单 → 开发 → 交付 → 验收 → 计收)与按进度确认收入。
|
||
"""
|
||
|
||
__tablename__ = "custom_projects"
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||
name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||
customer: Mapped[str | None] = mapped_column(String(128), nullable=True) # 客户 / 办事处
|
||
region: Mapped[str | None] = mapped_column(String(64), nullable=True) # 区域(省/办)
|
||
amount: Mapped[float | None] = mapped_column(Float, nullable=True) # 合同/订单金额
|
||
currency: Mapped[str] = mapped_column(String(8), default="CNY", nullable=False)
|
||
|
||
stage: Mapped[str] = mapped_column(String(32), default="signed", nullable=False, index=True)
|
||
progress: Mapped[float] = mapped_column(Float, default=0.0, nullable=False) # 开发进度 %
|
||
manual_ratio: Mapped[float | None] = mapped_column(Float, nullable=True) # 手动收入确认比例(覆盖阶段推算)
|
||
revenue_amount: Mapped[float | None] = mapped_column(Float, nullable=True) # 已计收金额
|
||
|
||
owner: Mapped[str | None] = mapped_column(String(64), nullable=True) # 交付负责人(单人)
|
||
other_owners: Mapped[str | None] = mapped_column(String(512), nullable=True) # 已停用:定制交付不做多负责人
|
||
members: Mapped[str | None] = mapped_column(String(512), nullable=True) # 团队成员
|
||
entry_date: Mapped[str | None] = mapped_column(String(16), nullable=True) # 入库时间(YYYY-MM-DD)
|
||
sign_date: Mapped[str | None] = mapped_column(String(16), nullable=True) # 签单/下单时间
|
||
deliver_date: Mapped[str | None] = mapped_column(String(16), nullable=True) # 交付时间
|
||
accept_date: Mapped[str | None] = mapped_column(String(16), nullable=True) # 验收时间
|
||
revenue_date: Mapped[str | None] = mapped_column(String(16), nullable=True) # 计收时间
|
||
|
||
invoice_amount: Mapped[float | None] = mapped_column(Float, nullable=True) # 已开票金额
|
||
received_amount: Mapped[float | None] = mapped_column(Float, nullable=True) # 已回款金额
|
||
|
||
period_id: Mapped[int | None] = mapped_column(
|
||
Integer, ForeignKey("periods.id", ondelete="SET NULL"), nullable=True
|
||
) # 最近一次更新所在周期
|
||
remark: Mapped[str | None] = mapped_column(Text, nullable=True) # 最新状态说明
|
||
is_key: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||
archived: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||
source: Mapped[str] = mapped_column(String(16), default="import", nullable=False)
|
||
|
||
updates: Mapped[list["CustomUpdate"]] = relationship(
|
||
back_populates="project", cascade="all, delete-orphan"
|
||
)
|
||
|
||
def to_dict(self, with_revenue: bool = True):
|
||
from .constants import custom_revenue_ratio
|
||
|
||
ratio = custom_revenue_ratio(self.stage, self.progress, self.manual_ratio)
|
||
data = {
|
||
"id": self.id,
|
||
"name": self.name,
|
||
"customer": self.customer,
|
||
"region": self.region,
|
||
"amount": self.amount,
|
||
"stage": self.stage,
|
||
"progress": round(self.progress or 0, 1),
|
||
"manual_ratio": self.manual_ratio,
|
||
"revenue_amount": self.revenue_amount,
|
||
"owner": self.owner,
|
||
"members": split_people(self.members),
|
||
"entry_date": self.entry_date,
|
||
"sign_date": self.sign_date,
|
||
"deliver_date": self.deliver_date,
|
||
"accept_date": self.accept_date,
|
||
"revenue_date": self.revenue_date,
|
||
"invoice_amount": self.invoice_amount,
|
||
"received_amount": self.received_amount,
|
||
"period_id": self.period_id,
|
||
"remark": self.remark,
|
||
"is_key": self.is_key,
|
||
"archived": self.archived,
|
||
"source": self.source,
|
||
"update_count": len(self.updates),
|
||
"updated_at": self.updated_at.isoformat(timespec="seconds") if self.updated_at else None,
|
||
}
|
||
if with_revenue:
|
||
data["revenue_ratio"] = round(ratio * 100, 1)
|
||
data["confirmed_amount"] = round((self.amount or 0) * ratio, 2) # 按进度可确认收入
|
||
data["pending_amount"] = round(
|
||
(self.amount or 0) - (self.revenue_amount or 0), 2
|
||
) # 待计收
|
||
data["pending_invoice"] = round(
|
||
max(0.0, (self.amount or 0) - (self.invoice_amount or 0)), 2
|
||
)
|
||
data["pending_received"] = round(
|
||
max(0.0, (self.amount or 0) - (self.received_amount or 0)), 2
|
||
)
|
||
return data
|
||
|
||
|
||
class CustomUpdate(Base, TimestampMixin):
|
||
"""定制项目的阶段流转 / 周期执行记录(独立表)。"""
|
||
|
||
__tablename__ = "custom_updates"
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||
custom_project_id: Mapped[int] = mapped_column(
|
||
Integer, ForeignKey("custom_projects.id", ondelete="CASCADE"), nullable=False, index=True
|
||
)
|
||
period_id: Mapped[int] = mapped_column(
|
||
Integer, ForeignKey("periods.id", ondelete="CASCADE"), nullable=False, index=True
|
||
)
|
||
stage: Mapped[str | None] = mapped_column(String(32), nullable=True) # 该时点所处阶段
|
||
content: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||
summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||
progress: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||
amount_delta: Mapped[float | None] = mapped_column(Float, nullable=True) # 本期新增下单金额
|
||
revenue_delta: Mapped[float | None] = mapped_column(Float, nullable=True) # 本期新增计收
|
||
risk: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||
next_step: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||
owner: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||
source: Mapped[str] = mapped_column(String(16), default="manual", nullable=False)
|
||
raw_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||
model: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||
|
||
project: Mapped[CustomProject] = relationship(back_populates="updates")
|
||
period: Mapped[Period] = relationship()
|
||
|
||
def to_dict(self):
|
||
return {
|
||
"id": self.id,
|
||
"custom_project_id": self.custom_project_id,
|
||
"project_name": self.project.name if self.project else None,
|
||
"period_id": self.period_id,
|
||
"period_label": self.period.label if self.period else None,
|
||
"period_full": self.period.to_dict()["full_label"] if self.period else None,
|
||
"sort_key": self.period.sort_key if self.period else 0,
|
||
"stage": self.stage,
|
||
"content": self.content,
|
||
"summary": self.summary,
|
||
"progress": self.progress,
|
||
"amount_delta": self.amount_delta,
|
||
"revenue_delta": self.revenue_delta,
|
||
"risk": self.risk,
|
||
"next_step": self.next_step,
|
||
"owner": self.owner,
|
||
"source": self.source,
|
||
"model": self.model,
|
||
"created_at": self.created_at.isoformat(timespec="seconds") if self.created_at else None,
|
||
}
|
||
|
||
|
||
class Staff(Base, TimestampMixin):
|
||
"""人力资源库成员。
|
||
|
||
项目管理 / 定制交付的负责人一律从这里选取;归档后不再出现在下拉里,
|
||
历史记录中的人名保持不变。
|
||
"""
|
||
|
||
__tablename__ = "staff"
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||
name: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||
group: Mapped[str | None] = mapped_column(String(64), nullable=True) # 归属小组
|
||
affiliation: Mapped[str | None] = mapped_column(String(32), nullable=True) # 人员归属(汇智/云数/外服)
|
||
note: Mapped[str | None] = mapped_column(Text, nullable=True) # 说明 / 备注
|
||
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||
archived: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||
|
||
def to_dict(self):
|
||
return {
|
||
"id": self.id,
|
||
"name": self.name,
|
||
"group": self.group,
|
||
"affiliation": self.affiliation,
|
||
"note": self.note,
|
||
"sort_order": self.sort_order,
|
||
"archived": self.archived,
|
||
"updated_at": self.updated_at.isoformat(timespec="seconds") if self.updated_at else None,
|
||
}
|
||
|
||
|
||
class Setting(Base):
|
||
"""键值配置(AI 参数等,可在页面设置中修改)。"""
|
||
|
||
__tablename__ = "settings"
|
||
|
||
key: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||
value: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||
|
||
|
||
class ImportLog(Base):
|
||
"""AI 整理留痕。"""
|
||
|
||
__tablename__ = "import_logs"
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||
project_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||
period_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||
raw_input: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||
parsed_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||
mode: Mapped[str] = mapped_column(String(16), default="rule", nullable=False)
|
||
model: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||
created_at: Mapped[datetime] = mapped_column(DateTime, default=now, nullable=False)
|
||
|
||
def to_dict(self):
|
||
return {
|
||
"id": self.id,
|
||
"project_id": self.project_id,
|
||
"period_id": self.period_id,
|
||
"mode": self.mode,
|
||
"model": self.model,
|
||
"raw_input": (self.raw_input or "")[:400],
|
||
"created_at": self.created_at.isoformat(timespec="seconds") if self.created_at else None,
|
||
}
|