"""数据模型。 层级: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) # ---- 市场/销售侧台账字段(来源《定开项目.xlsx》,2026-09 起)---- origin: Mapped[str | None] = mapped_column(String(32), nullable=True) # 项目归属:新签订单/存量项目/汇智直签 project_code: Mapped[str | None] = mapped_column( String(64), nullable=True, index=True ) # 项目ID(市场编号,导入时用于去重) office: Mapped[str | None] = mapped_column(String(64), nullable=True) # 办事处 industry: Mapped[str | None] = mapped_column(String(32), nullable=True) # 行业:安平/政府/企业/教育… man_days: Mapped[float | None] = mapped_column(Float, nullable=True) # 市场下单人天 market_owner: Mapped[str | None] = mapped_column( String(128), nullable=True ) # 市场责任人(销售侧;多人用 / 分隔,与花名册无关) project_status: Mapped[str | None] = mapped_column( String(32), nullable=True ) # 项目状态:已验收/待开发/交付完成/交付中/退单 accept_plan: Mapped[str | None] = mapped_column( String(32), nullable=True ) # 推动验收计划(Q1..Q4 / 27年) risk_level: Mapped[str | None] = mapped_column(String(32), nullable=True) # 风险值:1.高风险 / 2.低风险 update_date: Mapped[str | None] = mapped_column( String(16), nullable=True, index=True ) # 更新时间 YYYY-MM-DD:该行数据最后一次变动的日期,用于按周查看变化 accept_date_real: Mapped[str | None] = mapped_column( String(16), nullable=True, index=True ) # 验收日期 YYYY-MM-DD(真实日期):与 accept_date(季度文本)区分,用于「本月/本年验收」统计 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, # 市场/销售侧台账字段 "origin": self.origin, "project_code": self.project_code, "office": self.office, "industry": self.industry, "man_days": self.man_days, "market_owner": self.market_owner, "project_status": self.project_status, "accept_plan": self.accept_plan, "risk_level": self.risk_level, "update_date": self.update_date, "accept_date_real": self.accept_date_real, "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, }