- 项目详情页三阶段里程碑管理(前期/制作/后期) - 制作卡片改用180px ECharts圆环进度图+右侧数据列表 - 修复损耗率双重计算bug(测试秒数不再重复计入超产) - 新增飞书推送服务、豆包AI风险分析、APScheduler定时报告 - 项目列表页增强(筛选/排序/批量操作/废弃功能) - 成员详情页产出时间轴+效率对比 - 成本页固定开支管理 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
399 lines
16 KiB
Python
399 lines
16 KiB
Python
"""数据库模型 —— 所有表定义"""
|
|
from sqlalchemy import (
|
|
Column, Integer, String, Float, Date, DateTime, Text,
|
|
ForeignKey, Enum as SAEnum, JSON
|
|
)
|
|
from sqlalchemy.orm import relationship
|
|
from sqlalchemy.sql import func
|
|
from database import Base
|
|
import enum
|
|
|
|
|
|
# ──────────────────────────── 权限标识符定义 ────────────────────────────
|
|
|
|
ALL_PERMISSIONS = [
|
|
# 仪表盘
|
|
("dashboard:view", "查看仪表盘", "仪表盘"),
|
|
# 项目管理
|
|
("project:view", "查看项目", "项目管理"),
|
|
("project:create", "创建项目", "项目管理"),
|
|
("project:edit", "编辑项目", "项目管理"),
|
|
("project:delete", "删除项目", "项目管理"),
|
|
("project:complete", "确认完成项目", "项目管理"),
|
|
# 内容提交
|
|
("submission:view", "查看提交记录", "内容提交"),
|
|
("submission:create", "新增提交", "内容提交"),
|
|
# 成本管理 —— 按类型细分
|
|
("cost_ai:view", "查看AI工具成本", "成本管理"),
|
|
("cost_ai:create", "录入AI工具成本", "成本管理"),
|
|
("cost_ai:delete", "删除AI工具成本", "成本管理"),
|
|
("cost_outsource:view", "查看外包成本", "成本管理"),
|
|
("cost_outsource:create", "录入外包成本", "成本管理"),
|
|
("cost_outsource:delete", "删除外包成本", "成本管理"),
|
|
("cost_overhead:view", "查看固定开支", "成本管理"),
|
|
("cost_overhead:create", "录入固定开支", "成本管理"),
|
|
("cost_overhead:delete", "删除固定开支", "成本管理"),
|
|
("cost_labor:view", "查看人力调整", "成本管理"),
|
|
("cost_labor:create", "录入人力调整", "成本管理"),
|
|
# 用户与角色
|
|
("user:view", "查看用户列表", "用户与角色"),
|
|
("user:manage", "管理用户", "用户与角色"),
|
|
("role:manage", "管理角色", "用户与角色"),
|
|
# 结算与效率
|
|
("settlement:view", "查看结算报告", "结算与效率"),
|
|
("efficiency:view", "查看团队效率", "结算与效率"),
|
|
]
|
|
|
|
PERMISSION_KEYS = [p[0] for p in ALL_PERMISSIONS]
|
|
|
|
# 成本查看权限集合(用于判断是否有任一成本查看权限)
|
|
COST_VIEW_PERMS = ["cost_ai:view", "cost_outsource:view", "cost_overhead:view", "cost_labor:view"]
|
|
|
|
# 旧权限 → 新权限映射(用于数据库迁移)
|
|
COST_PERM_MIGRATION = {
|
|
"cost:view": ["cost_ai:view", "cost_outsource:view", "cost_overhead:view", "cost_labor:view"],
|
|
"cost:create": ["cost_ai:create", "cost_outsource:create", "cost_overhead:create", "cost_labor:create"],
|
|
"cost:delete": ["cost_ai:delete", "cost_outsource:delete", "cost_overhead:delete"],
|
|
}
|
|
|
|
# 内置角色定义
|
|
BUILTIN_ROLES = {
|
|
"超级管理员": {
|
|
"description": "系统最高权限,拥有全部功能",
|
|
"permissions": PERMISSION_KEYS[:], # 全部
|
|
},
|
|
"主管": {
|
|
"description": "管理项目和成本,不可管理用户和角色",
|
|
"permissions": [
|
|
"dashboard:view",
|
|
"project:view", "project:create", "project:edit", "project:complete",
|
|
"submission:view", "submission:create",
|
|
"cost_ai:view", "cost_ai:create", "cost_ai:delete",
|
|
"cost_outsource:view", "cost_outsource:create", "cost_outsource:delete",
|
|
"cost_overhead:view", "cost_overhead:create", "cost_overhead:delete",
|
|
"cost_labor:view", "cost_labor:create",
|
|
"user:view",
|
|
"settlement:view", "efficiency:view",
|
|
],
|
|
},
|
|
"组长": {
|
|
"description": "管理本组提交和查看成本",
|
|
"permissions": [
|
|
"project:view", "project:create",
|
|
"submission:view", "submission:create",
|
|
"cost_ai:view", "cost_ai:create",
|
|
"efficiency:view",
|
|
],
|
|
},
|
|
"成员": {
|
|
"description": "提交内容和查看项目",
|
|
"permissions": [
|
|
"project:view",
|
|
"submission:view", "submission:create",
|
|
],
|
|
},
|
|
}
|
|
|
|
|
|
# ──────────────────────────── 枚举定义 ────────────────────────────
|
|
|
|
class ProjectType(str, enum.Enum):
|
|
CLIENT_FORMAL = "客户正式项目"
|
|
CLIENT_TEST = "客户测试项目"
|
|
INTERNAL_ORIGINAL = "内部原创项目"
|
|
INTERNAL_TEST = "内部测试项目"
|
|
|
|
|
|
class ProjectStatus(str, enum.Enum):
|
|
IN_PROGRESS = "制作中"
|
|
COMPLETED = "已完成"
|
|
ABANDONED = "废弃"
|
|
|
|
|
|
class PhaseGroup(str, enum.Enum):
|
|
PRE = "前期"
|
|
PRODUCTION = "制作"
|
|
POST = "后期"
|
|
|
|
|
|
class WorkType(str, enum.Enum):
|
|
PRODUCTION = "制作"
|
|
TEST = "测试"
|
|
PLAN = "方案"
|
|
|
|
|
|
class ContentType(str, enum.Enum):
|
|
ANIMATION = "内容制作"
|
|
DESIGN = "设定策划"
|
|
EDITING = "剪辑后期"
|
|
OTHER = "其他"
|
|
|
|
|
|
class SubmitTo(str, enum.Enum):
|
|
LEADER = "组长"
|
|
PRODUCER = "制片"
|
|
INTERNAL = "内部"
|
|
EXTERNAL = "外部"
|
|
|
|
|
|
class SubscriptionPeriod(str, enum.Enum):
|
|
MONTHLY = "月"
|
|
YEARLY = "年"
|
|
|
|
|
|
class CostAllocationType(str, enum.Enum):
|
|
PROJECT = "指定项目"
|
|
TEAM = "内容组整体"
|
|
MANUAL = "手动分摊"
|
|
|
|
|
|
class OutsourceType(str, enum.Enum):
|
|
ANIMATION = "动画"
|
|
EDITING = "剪辑"
|
|
FULL_EPISODE = "整集"
|
|
|
|
|
|
class OverheadCostType(str, enum.Enum):
|
|
OFFICE_RENT = "办公室租金"
|
|
UTILITIES = "水电费"
|
|
|
|
|
|
# ──────────────────────────── 角色 ────────────────────────────
|
|
|
|
class Role(Base):
|
|
__tablename__ = "roles"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
name = Column(String(50), unique=True, nullable=False)
|
|
description = Column(String(200), nullable=True)
|
|
permissions = Column(JSON, nullable=False, default=[]) # 权限标识符列表
|
|
is_system = Column(Integer, nullable=False, default=0) # 1=内置角色不可删
|
|
created_at = Column(DateTime, server_default=func.now())
|
|
|
|
users = relationship("User", back_populates="role_ref")
|
|
|
|
def has_permission(self, perm: str) -> bool:
|
|
return perm in (self.permissions or [])
|
|
|
|
|
|
# ──────────────────────────── 用户 ────────────────────────────
|
|
|
|
class User(Base):
|
|
__tablename__ = "users"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
username = Column(String(50), unique=True, nullable=False, index=True)
|
|
password_hash = Column(String(255), nullable=False)
|
|
name = Column(String(50), nullable=False)
|
|
phase_group = Column(SAEnum(PhaseGroup), nullable=False)
|
|
role_id = Column(Integer, ForeignKey("roles.id"), nullable=False)
|
|
monthly_salary = Column(Float, nullable=False, default=0)
|
|
bonus = Column(Float, nullable=False, default=0)
|
|
social_insurance = Column(Float, nullable=False, default=0)
|
|
is_active = Column(Integer, nullable=False, default=1)
|
|
created_at = Column(DateTime, server_default=func.now())
|
|
|
|
# 关系
|
|
role_ref = relationship("Role", back_populates="users")
|
|
submissions = relationship("Submission", back_populates="user")
|
|
led_projects = relationship("Project", back_populates="leader")
|
|
|
|
@property
|
|
def role_name(self):
|
|
return self.role_ref.name if self.role_ref else ""
|
|
|
|
@property
|
|
def permissions(self):
|
|
return self.role_ref.permissions if self.role_ref else []
|
|
|
|
def has_permission(self, perm: str) -> bool:
|
|
return self.role_ref.has_permission(perm) if self.role_ref else False
|
|
|
|
@property
|
|
def monthly_total_cost(self):
|
|
return (self.monthly_salary or 0) + (self.bonus or 0) + (self.social_insurance or 0)
|
|
|
|
@property
|
|
def daily_cost(self):
|
|
from config import WORKING_DAYS_PER_MONTH
|
|
return round(self.monthly_total_cost / WORKING_DAYS_PER_MONTH, 2) if self.monthly_total_cost else 0
|
|
|
|
|
|
# ──────────────────────────── 项目 ────────────────────────────
|
|
|
|
class Project(Base):
|
|
__tablename__ = "projects"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
name = Column(String(100), nullable=False)
|
|
project_type = Column(SAEnum(ProjectType), nullable=False)
|
|
status = Column(SAEnum(ProjectStatus), nullable=False, default=ProjectStatus.IN_PROGRESS)
|
|
leader_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
|
current_phase = Column(SAEnum(PhaseGroup), nullable=False, default=PhaseGroup.PRE)
|
|
episode_duration_minutes = Column(Float, nullable=False)
|
|
episode_count = Column(Integer, nullable=False)
|
|
estimated_completion_date = Column(Date, nullable=True)
|
|
actual_completion_date = Column(Date, nullable=True)
|
|
contract_amount = Column(Float, nullable=True)
|
|
created_at = Column(DateTime, server_default=func.now())
|
|
|
|
leader = relationship("User", back_populates="led_projects")
|
|
submissions = relationship("Submission", back_populates="project")
|
|
outsource_costs = relationship("OutsourceCost", back_populates="project")
|
|
ai_tool_allocations = relationship("AIToolCostAllocation", back_populates="project")
|
|
milestones = relationship("ProjectMilestone", back_populates="project", cascade="all, delete-orphan")
|
|
|
|
@property
|
|
def target_total_seconds(self):
|
|
return int(self.episode_duration_minutes * 60 * self.episode_count)
|
|
|
|
|
|
# ──────────────────────────── 内容提交 ────────────────────────────
|
|
|
|
class Submission(Base):
|
|
__tablename__ = "submissions"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
|
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
|
|
project_phase = Column(SAEnum(PhaseGroup), nullable=False)
|
|
work_type = Column(SAEnum(WorkType), nullable=False)
|
|
content_type = Column(SAEnum(ContentType), nullable=False)
|
|
duration_minutes = Column(Float, nullable=True, default=0)
|
|
duration_seconds = Column(Float, nullable=True, default=0)
|
|
total_seconds = Column(Float, nullable=False, default=0)
|
|
hours_spent = Column(Float, nullable=True)
|
|
submit_to = Column(SAEnum(SubmitTo), nullable=False)
|
|
description = Column(Text, nullable=True)
|
|
submit_date = Column(Date, nullable=False)
|
|
created_at = Column(DateTime, server_default=func.now())
|
|
|
|
user = relationship("User", back_populates="submissions")
|
|
project = relationship("Project", back_populates="submissions")
|
|
history = relationship("SubmissionHistory", back_populates="submission")
|
|
|
|
|
|
# ──────────────────────────── AI 工具成本 ────────────────────────────
|
|
|
|
class AIToolCost(Base):
|
|
__tablename__ = "ai_tool_costs"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
tool_name = Column(String(100), nullable=False)
|
|
subscription_period = Column(SAEnum(SubscriptionPeriod), nullable=False)
|
|
amount = Column(Float, nullable=False)
|
|
allocation_type = Column(SAEnum(CostAllocationType), nullable=False)
|
|
project_id = Column(Integer, ForeignKey("projects.id"), nullable=True)
|
|
recorded_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
|
record_date = Column(Date, nullable=False)
|
|
created_at = Column(DateTime, server_default=func.now())
|
|
|
|
allocations = relationship("AIToolCostAllocation", back_populates="ai_tool_cost")
|
|
|
|
|
|
class AIToolCostAllocation(Base):
|
|
__tablename__ = "ai_tool_cost_allocations"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
ai_tool_cost_id = Column(Integer, ForeignKey("ai_tool_costs.id"), nullable=False)
|
|
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
|
|
percentage = Column(Float, nullable=False)
|
|
|
|
ai_tool_cost = relationship("AIToolCost", back_populates="allocations")
|
|
project = relationship("Project", back_populates="ai_tool_allocations")
|
|
|
|
|
|
# ──────────────────────────── 外包成本 ────────────────────────────
|
|
|
|
class OutsourceCost(Base):
|
|
__tablename__ = "outsource_costs"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
|
|
outsource_type = Column(SAEnum(OutsourceType), nullable=False)
|
|
episode_start = Column(Integer, nullable=True)
|
|
episode_end = Column(Integer, nullable=True)
|
|
amount = Column(Float, nullable=False)
|
|
recorded_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
|
record_date = Column(Date, nullable=False)
|
|
created_at = Column(DateTime, server_default=func.now())
|
|
|
|
project = relationship("Project", back_populates="outsource_costs")
|
|
|
|
|
|
# ──────────────────────────── 人力成本手动调整 ────────────────────────────
|
|
|
|
class CostOverride(Base):
|
|
__tablename__ = "cost_overrides"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
|
date = Column(Date, nullable=False)
|
|
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
|
|
override_amount = Column(Float, nullable=False)
|
|
adjusted_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
|
reason = Column(Text, nullable=True)
|
|
created_at = Column(DateTime, server_default=func.now())
|
|
|
|
|
|
# ──────────────────────────── 提交历史版本 ────────────────────────────
|
|
|
|
class SubmissionHistory(Base):
|
|
__tablename__ = "submission_history"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
submission_id = Column(Integer, ForeignKey("submissions.id"), nullable=False)
|
|
changed_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
|
change_reason = Column(Text, nullable=False)
|
|
old_data = Column(JSON, nullable=False)
|
|
new_data = Column(JSON, nullable=False)
|
|
created_at = Column(DateTime, server_default=func.now())
|
|
|
|
submission = relationship("Submission", back_populates="history")
|
|
|
|
|
|
# ──────────────────────────── 固定开支 ────────────────────────────
|
|
|
|
class OverheadCost(Base):
|
|
__tablename__ = "overhead_costs"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
cost_type = Column(SAEnum(OverheadCostType), nullable=False)
|
|
amount = Column(Float, nullable=False)
|
|
record_month = Column(String(7), nullable=False)
|
|
note = Column(Text, nullable=True)
|
|
recorded_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
|
created_at = Column(DateTime, server_default=func.now())
|
|
|
|
|
|
# ──────────────────────────── 项目里程碑 ────────────────────────────
|
|
|
|
class ProjectMilestone(Base):
|
|
__tablename__ = "project_milestones"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
|
|
name = Column(String(100), nullable=False)
|
|
phase = Column(SAEnum(PhaseGroup), nullable=False)
|
|
is_completed = Column(Integer, nullable=False, default=0) # 0/1
|
|
completed_at = Column(DateTime, nullable=True)
|
|
sort_order = Column(Integer, nullable=False, default=0)
|
|
|
|
project = relationship("Project", back_populates="milestones")
|
|
|
|
|
|
# 默认里程碑模板
|
|
DEFAULT_MILESTONES = [
|
|
# 前期
|
|
{"name": "策划案", "phase": "前期", "sort_order": 1},
|
|
{"name": "剧本", "phase": "前期", "sort_order": 2},
|
|
{"name": "分镜", "phase": "前期", "sort_order": 3},
|
|
{"name": "人设图", "phase": "前期", "sort_order": 4},
|
|
{"name": "场景图", "phase": "前期", "sort_order": 5},
|
|
# 后期
|
|
{"name": "配音", "phase": "后期", "sort_order": 1},
|
|
{"name": "音效", "phase": "后期", "sort_order": 2},
|
|
{"name": "修补镜头", "phase": "后期", "sort_order": 3},
|
|
{"name": "杂项", "phase": "后期", "sort_order": 4},
|
|
]
|