feat: 内部事务提交功能(培训/招聘面试/内部其他)
All checks were successful
Build and Deploy Backend / build-and-deploy (push) Successful in 1m44s
Build and Deploy Web / build-and-deploy (push) Successful in 57s

- 新增 ContentType: TRAINING, RECRUITMENT, INTERNAL_OTHER
- 新增 PhaseGroup: INTERNAL (内部事务)
- 前端选"内部事务"项目时隐藏项目阶段/工作类型/产出时长
- 内容类型改显示为"事务类型"(培训/招聘面试/内部其他)
- 报告和仪表盘中排除内部事务项目
- 内部事务成本后续按管理成本逻辑分摊到所有项目

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
seaislee1209 2026-03-10 10:18:48 +08:00
parent bc9fa5a798
commit becfd74efd
6 changed files with 117 additions and 60 deletions

View File

@ -161,13 +161,24 @@ def init_roles_and_admin():
'SCRIPT','STORYBOARD','CHARACTER_DESIGN','SCENE_DESIGN', 'SCRIPT','STORYBOARD','CHARACTER_DESIGN','SCENE_DESIGN',
'PROP_DESIGN','ANIMATION','DUBBING','AI_DUBBING','SOUND_EFFECTS', 'PROP_DESIGN','ANIMATION','DUBBING','AI_DUBBING','SOUND_EFFECTS',
'SHOT_REPAIR','EDITING','MUSIC','SUBTITLE','OTHER', 'SHOT_REPAIR','EDITING','MUSIC','SUBTITLE','OTHER',
'DESIGN') NOT NULL 'DESIGN','TRAINING','RECRUITMENT','INTERNAL_OTHER') NOT NULL
""")) """))
conn.commit() conn.commit()
print("[MIGRATE] expanded content_type enum") print("[MIGRATE] expanded content_type enum")
except Exception: except Exception:
pass # 已经扩展过 pass # 已经扩展过
# MySQL: 扩展 project_phase 枚举(加入 INTERNAL/内部事务)
try:
conn.execute(text("""
ALTER TABLE submissions MODIFY COLUMN project_phase
ENUM('PRE','PRODUCTION','POST','INTERNAL') NOT NULL
"""))
conn.commit()
print("[MIGRATE] expanded project_phase enum")
except Exception:
pass # 已经扩展过
# MySQL: 扩展 work_type 枚举(加入 REVISION/修改) # MySQL: 扩展 work_type 枚举(加入 REVISION/修改)
try: try:
conn.execute(text(""" conn.execute(text("""

View File

@ -126,6 +126,7 @@ class PhaseGroup(str, enum.Enum):
PRE = "前期" PRE = "前期"
PRODUCTION = "中期" PRODUCTION = "中期"
POST = "后期" POST = "后期"
INTERNAL = "内部事务"
class WorkType(str, enum.Enum): class WorkType(str, enum.Enum):
@ -159,6 +160,10 @@ class ContentType(str, enum.Enum):
SUBTITLE = "字幕" SUBTITLE = "字幕"
# 通用 # 通用
OTHER = "其他" OTHER = "其他"
# 内部事务
TRAINING = "培训"
RECRUITMENT = "招聘面试"
INTERNAL_OTHER = "内部其他"
class SubmitTo(str, enum.Enum): class SubmitTo(str, enum.Enum):
@ -449,7 +454,8 @@ CONTENT_PHASE_MAP = {
"动画制作": "中期", "动画制作": "中期",
"AI配音": "后期", "音效": "后期", "AI配音": "后期", "音效": "后期",
"修补镜头": "后期", "剪辑": "后期", "音乐/BGM": "后期", "字幕": "后期", "修补镜头": "后期", "剪辑": "后期", "音乐/BGM": "后期", "字幕": "后期",
"培训": "内部事务", "招聘面试": "内部事务", "内部其他": "内部事务",
} }
# 项目级内容类型(不需要选集数) # 项目级内容类型(不需要选集数)
PROJECT_LEVEL_TYPES = {"策划案", "大纲/梗概", "概念设计图", "测试片"} PROJECT_LEVEL_TYPES = {"策划案", "大纲/梗概", "概念设计图", "测试片", "培训", "招聘面试", "内部其他"}

View File

@ -26,7 +26,10 @@ def get_dashboard(
): ):
"""全局仪表盘数据""" """全局仪表盘数据"""
# 项目概览 # 项目概览
active = db.query(Project).filter(Project.status == ProjectStatus.IN_PROGRESS).all() active = db.query(Project).filter(
Project.status == ProjectStatus.IN_PROGRESS,
Project.name != "内部事务",
).all()
completed = db.query(Project).filter(Project.status == ProjectStatus.COMPLETED).all() completed = db.query(Project).filter(Project.status == ProjectStatus.COMPLETED).all()
abandoned = db.query(Project).filter(Project.status == ProjectStatus.ABANDONED).all() abandoned = db.query(Project).filter(Project.status == ProjectStatus.ABANDONED).all()

View File

@ -88,9 +88,9 @@ def create_submission(
raise HTTPException(status_code=422, detail="请填写投入时长") raise HTTPException(status_code=422, detail="请填写投入时长")
# 产出时长校验:前期内容不需要,中期/后期内容必须 > 0 # 产出时长校验:前期内容不需要,中期/后期内容必须 > 0
PRE_PHASE_TYPES = {'策划案', '大纲/梗概', '概念设计图', '测试片', '剧本', '分镜', '人设图', '场景图', '道具图'} NO_DURATION_TYPES = {'策划案', '大纲/梗概', '概念设计图', '测试片', '剧本', '分镜', '人设图', '场景图', '道具图', '培训', '招聘面试', '内部其他'}
content_val = req.content_type.value if hasattr(req.content_type, 'value') else req.content_type content_val = req.content_type.value if hasattr(req.content_type, 'value') else req.content_type
if content_val not in PRE_PHASE_TYPES: if content_val not in NO_DURATION_TYPES:
total_secs = (req.duration_minutes or 0) * 60 + (req.duration_seconds or 0) total_secs = (req.duration_minutes or 0) * 60 + (req.duration_seconds or 0)
if total_secs <= 0: if total_secs <= 0:
raise HTTPException(status_code=422, detail="请填写产出时长") raise HTTPException(status_code=422, detail="请填写产出时长")
@ -219,7 +219,7 @@ def update_submission(
sub.total_seconds = (sub.duration_minutes or 0) * 60 + (sub.duration_seconds or 0) sub.total_seconds = (sub.duration_minutes or 0) * 60 + (sub.duration_seconds or 0)
# 产出时长校验:前期内容不需要,中期/后期内容必须 > 0 # 产出时长校验:前期内容不需要,中期/后期内容必须 > 0
PRE_PHASE_TYPES = {'策划案', '大纲/梗概', '概念设计图', '测试片', '剧本', '分镜', '人设图', '场景图', '道具图'} NO_DURATION_TYPES = {'策划案', '大纲/梗概', '概念设计图', '测试片', '剧本', '分镜', '人设图', '场景图', '道具图', '培训', '招聘面试', '内部其他'}
content_val = sub.content_type.value if hasattr(sub.content_type, 'value') else sub.content_type content_val = sub.content_type.value if hasattr(sub.content_type, 'value') else sub.content_type
if content_val not in PRE_PHASE_TYPES and sub.total_seconds <= 0: if content_val not in PRE_PHASE_TYPES and sub.total_seconds <= 0:
raise HTTPException(status_code=422, detail="请填写产出时长") raise HTTPException(status_code=422, detail="请填写产出时长")

View File

@ -19,6 +19,8 @@ from calculations import (
) )
from services.ai_service import generate_report_summary from services.ai_service import generate_report_summary
INTERNAL_PROJECT_NAME = "内部事务"
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -88,7 +90,8 @@ def generate_daily_report(db: Session) -> dict:
# 进行中项目 # 进行中项目
active_projects = db.query(Project).filter( active_projects = db.query(Project).filter(
Project.status == ProjectStatus.IN_PROGRESS Project.status == ProjectStatus.IN_PROGRESS,
Project.name != INTERNAL_PROJECT_NAME,
).all() ).all()
projects_data = [] projects_data = []
@ -190,7 +193,8 @@ def generate_weekly_report(db: Session) -> dict:
avg_daily = round(week_total_secs / max(1, len(week_submitter_ids)) / max(1, working_days), 1) avg_daily = round(week_total_secs / max(1, len(week_submitter_ids)) / max(1, working_days), 1)
active_projects = db.query(Project).filter( active_projects = db.query(Project).filter(
Project.status == ProjectStatus.IN_PROGRESS Project.status == ProjectStatus.IN_PROGRESS,
Project.name != INTERNAL_PROJECT_NAME,
).all() ).all()
projects_data = [] projects_data = []
@ -340,7 +344,8 @@ def generate_monthly_report(db: Session) -> dict:
month_submitters = set(s.user_id for s in month_subs) month_submitters = set(s.user_id for s in month_subs)
all_projects = db.query(Project).filter( all_projects = db.query(Project).filter(
Project.status.in_([ProjectStatus.IN_PROGRESS, ProjectStatus.COMPLETED]) Project.status.in_([ProjectStatus.IN_PROGRESS, ProjectStatus.COMPLETED]),
Project.name != INTERNAL_PROJECT_NAME,
).all() ).all()
completed_this_month = [ completed_this_month = [
@ -467,7 +472,8 @@ def analyze_project_risks(db: Session) -> list:
""" """
today = date.today() today = date.today()
active_projects = db.query(Project).filter( active_projects = db.query(Project).filter(
Project.status == ProjectStatus.IN_PROGRESS Project.status == ProjectStatus.IN_PROGRESS,
Project.name != INTERNAL_PROJECT_NAME,
).all() ).all()
risks = [] risks = []

View File

@ -70,6 +70,7 @@
<el-option v-for="p in activeProjects" :key="p.id" :label="p.name" :value="p.id" /> <el-option v-for="p in activeProjects" :key="p.id" :label="p.name" :value="p.id" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<template v-if="!isInternalProject">
<el-row :gutter="16"> <el-row :gutter="16">
<el-col :span="12"> <el-col :span="12">
<el-form-item label="项目阶段" required> <el-form-item label="项目阶段" required>
@ -91,8 +92,9 @@
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row> </el-row>
<el-form-item label="内容类型" required> </template>
<el-select v-model="form.content_type" placeholder="本次提交的产出类型" style="width:100%"> <el-form-item :label="isInternalProject ? '事务类型' : '内容类型'" required>
<el-select v-model="form.content_type" :placeholder="isInternalProject ? '选择事务类型' : '本次提交的产出类型'" style="width:100%">
<el-option v-for="ct in filteredContentTypes" :key="ct" :label="ct" :value="ct" /> <el-option v-for="ct in filteredContentTypes" :key="ct" :label="ct" :value="ct" />
</el-select> </el-select>
</el-form-item> </el-form-item>
@ -157,6 +159,7 @@
<el-form-item label="所属项目"> <el-form-item label="所属项目">
<el-input :model-value="editForm._project_name" disabled /> <el-input :model-value="editForm._project_name" disabled />
</el-form-item> </el-form-item>
<template v-if="!isEditInternalProject">
<el-row :gutter="16"> <el-row :gutter="16">
<el-col :span="12"> <el-col :span="12">
<el-form-item label="项目阶段" required> <el-form-item label="项目阶段" required>
@ -178,7 +181,8 @@
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row> </el-row>
<el-form-item label="内容类型" required> </template>
<el-form-item :label="isEditInternalProject ? '事务类型' : '内容类型'" required>
<el-select v-model="editForm.content_type" style="width:100%"> <el-select v-model="editForm.content_type" style="width:100%">
<el-option v-for="ct in editFilteredContentTypes" :key="ct" :label="ct" :value="ct" /> <el-option v-for="ct in editFilteredContentTypes" :key="ct" :label="ct" :value="ct" />
</el-select> </el-select>
@ -250,6 +254,7 @@ const CONTENT_PHASE_MAP = {
'剧本': '前期', '分镜': '前期', '人设图': '前期', '场景图': '前期', '道具图': '前期', '剧本': '前期', '分镜': '前期', '人设图': '前期', '场景图': '前期', '道具图': '前期',
'动画制作': '中期', '动画制作': '中期',
'AI配音': '后期', '音效': '后期', '修补镜头': '后期', '剪辑': '后期', '音乐/BGM': '后期', '字幕': '后期', 'AI配音': '后期', '音效': '后期', '修补镜头': '后期', '剪辑': '后期', '音乐/BGM': '后期', '字幕': '后期',
'培训': '内部事务', '招聘面试': '内部事务', '内部其他': '内部事务',
} }
// //
@ -257,16 +262,17 @@ const PHASE_CONTENT_TYPES = {
'前期': ['策划案', '大纲/梗概', '概念设计图', '测试片', '剧本', '分镜', '人设图', '场景图', '道具图'], '前期': ['策划案', '大纲/梗概', '概念设计图', '测试片', '剧本', '分镜', '人设图', '场景图', '道具图'],
'中期': ['动画制作'], '中期': ['动画制作'],
'后期': ['AI配音', '音效', '修补镜头', '剪辑', '音乐/BGM', '字幕'], '后期': ['AI配音', '音效', '修补镜头', '剪辑', '音乐/BGM', '字幕'],
'内部事务': ['培训', '招聘面试', '内部其他'],
} }
// //
const PROJECT_LEVEL_TYPES = new Set(['策划案', '大纲/梗概', '概念设计图', '测试片']) const PROJECT_LEVEL_TYPES = new Set(['策划案', '大纲/梗概', '概念设计图', '测试片', '培训', '招聘面试', '内部其他'])
function needsEpisode(contentType) { function needsEpisode(contentType) {
return !PROJECT_LEVEL_TYPES.has(contentType) return !PROJECT_LEVEL_TYPES.has(contentType)
} }
// // +
const NO_DURATION_TYPES = new Set(PHASE_CONTENT_TYPES['前期']) const NO_DURATION_TYPES = new Set([...PHASE_CONTENT_TYPES['前期'], ...PHASE_CONTENT_TYPES['内部事务']])
function showDuration(contentType) { function showDuration(contentType) {
return !NO_DURATION_TYPES.has(contentType) return !NO_DURATION_TYPES.has(contentType)
} }
@ -310,9 +316,23 @@ const editForm = reactive({
episode_number: null, episode_number: null,
}) })
// //
const filteredContentTypes = computed(() => PHASE_CONTENT_TYPES[form.project_phase] || []) const INTERNAL_PROJECT_NAME = '内部事务'
const isInternalProject = computed(() => {
const proj = activeProjects.value.find(p => p.id === form.project_id)
return proj?.name === INTERNAL_PROJECT_NAME
})
const isEditInternalProject = computed(() => {
return editForm._project_name === INTERNAL_PROJECT_NAME
})
//
const filteredContentTypes = computed(() => {
if (isInternalProject.value) return PHASE_CONTENT_TYPES['内部事务']
return PHASE_CONTENT_TYPES[form.project_phase] || []
})
const editFilteredContentTypes = computed(() => { const editFilteredContentTypes = computed(() => {
if (isEditInternalProject.value) return PHASE_CONTENT_TYPES['内部事务']
const types = PHASE_CONTENT_TYPES[editForm.project_phase] || [] const types = PHASE_CONTENT_TYPES[editForm.project_phase] || []
// //
if (editForm.content_type && !types.includes(editForm.content_type)) { if (editForm.content_type && !types.includes(editForm.content_type)) {
@ -321,6 +341,17 @@ const editFilteredContentTypes = computed(() => {
return types return types
}) })
//
watch(() => form.project_id, () => {
if (isInternalProject.value) {
form.project_phase = '内部事务'
form.work_type = '制作'
form.content_type = PHASE_CONTENT_TYPES['内部事务'][0]
form.duration_minutes = 0
form.duration_seconds = 0
}
})
// //
watch(() => form.project_phase, (phase) => { watch(() => form.project_phase, (phase) => {
const types = PHASE_CONTENT_TYPES[phase] const types = PHASE_CONTENT_TYPES[phase]