fix: 预览按 draft-{id} 命名且重建全量同构 + C1 空台账改判数据待补 + 立项经费结余结论 + CLI 拆分 import-*/list-* + docx 占位黄底
This commit is contained in:
2
DEMO.md
2
DEMO.md
@@ -7,7 +7,7 @@ demo_video_url: ""
|
|||||||
## 演示一:一键初稿(对话)
|
## 演示一:一键初稿(对话)
|
||||||
|
|
||||||
1. 对话任务:"帮我生成 NSFC2026003 的结题报告初稿。"
|
1. 对话任务:"帮我生成 NSFC2026003 的结题报告初稿。"
|
||||||
2. 助手生成 Word + HTML 预览,并解读:论文指标 超额完成(4/3)、专利 持平(1/1)、决算合计 8.6 万元(占比表)。
|
2. 助手生成 Word + HTML 预览,并解读:论文指标 超额完成(4/3)、专利 持平(1/1)、决算合计 8.6 万元(占比表);项目录入立项经费时附结余与预算符合性结论。
|
||||||
3. 打开 HTML 预览:数据章节已成文,叙事章节有素材要点与黄底 [待补充] 占位。
|
3. 打开 HTML 预览:数据章节已成文,叙事章节有素材要点与黄底 [待补充] 占位。
|
||||||
|
|
||||||
## 演示二:批量准备结题季(数据管理)
|
## 演示二:批量准备结题季(数据管理)
|
||||||
|
|||||||
@@ -16,14 +16,14 @@
|
|||||||
|
|
||||||
### 录入台账
|
### 录入台账
|
||||||
|
|
||||||
1. "结题项目"表点顶栏「录入结题项目」:项目编号必填,预期指标粘贴 JSON;同一编号再次提交即更新
|
1. "结题项目"表点顶栏「录入结题项目」:项目编号必填,预期指标粘贴 JSON,立项经费(元)选填(用于决算结余说明);同一编号再次提交即更新
|
||||||
2. "成果清单"表点顶栏「录入成果」:选类型、填名称与期刊/专利号;代表性成果设为 1
|
2. "成果清单"表点顶栏「录入成果」:选类型、填名称与期刊/专利号;代表性成果设为 1
|
||||||
3. "决算明细"表点顶栏「录入决算明细」:科目、金额(元)、日期
|
3. "决算明细"表点顶栏「录入决算明细」:科目、金额(元)、日期
|
||||||
|
|
||||||
### 生成初稿
|
### 生成初稿
|
||||||
|
|
||||||
1. 在"结题项目"表点顶栏「生成结题初稿」,填项目编号提交
|
1. 在"结题项目"表点顶栏「生成结题初稿」,填项目编号提交
|
||||||
2. 完成后初稿记录表出现记录;左侧 HTML 报告区打开 `draft-preview`/`draft-编号` 预览
|
2. 完成后初稿记录表出现记录;左侧 HTML 报告区打开 `draft-编号` 预览(每份初稿独立文件,重建选中行只重建该份)
|
||||||
3. Word 文件路径在初稿记录的"Word 初稿"列,从 output 目录取用
|
3. Word 文件路径在初稿记录的"Word 初稿"列,从 output 目录取用
|
||||||
|
|
||||||
## 入口二:对话任务
|
## 入口二:对话任务
|
||||||
@@ -66,7 +66,8 @@
|
|||||||
## 常见卡住怎么办
|
## 常见卡住怎么办
|
||||||
|
|
||||||
- **提示"项目不存在或状态不是待结题"**:先在"结题项目"录入该项目,状态为"待结题"
|
- **提示"项目不存在或状态不是待结题"**:先在"结题项目"录入该项目,状态为"待结题"
|
||||||
- **完成情况全是"未达标"**:成果清单没录入或类型与预期指标不一致(类型须为 论文/专利/人才培养/其他)
|
- **完成情况全是"数据待补"**:成果清单还没录入;录入后重新生成即可对比
|
||||||
|
- **某类成果显示"未达标(实际 0)"**:该类型成果没录入或类型与预期指标不一致(类型须为 论文/专利/人才培养/其他)
|
||||||
- **Word 打不开/没有生成**:python-docx 依赖异常,重装技能;先用 HTML 预览查看内容
|
- **Word 打不开/没有生成**:python-docx 依赖异常,重装技能;先用 HTML 预览查看内容
|
||||||
- **叙事章节只有素材**:这是机制设计——素材要点来自你的成果与年度报告,正文须本人撰写
|
- **叙事章节只有素材**:这是机制设计——素材要点来自你的成果与年度报告,正文须本人撰写
|
||||||
- **决算章节提示"明细尚未录入"**:在"决算明细"表补录后重新生成
|
- **决算章节提示"明细尚未录入"**:在"决算明细"表补录后重新生成;录入立项经费后,决算章节会给出结余与预算符合性结论
|
||||||
|
|||||||
@@ -90,6 +90,8 @@
|
|||||||
"{{start}}",
|
"{{start}}",
|
||||||
"--end",
|
"--end",
|
||||||
"{{end}}",
|
"{{end}}",
|
||||||
|
"--approved-budget",
|
||||||
|
"{{approvedBudget}}",
|
||||||
"--expected-json",
|
"--expected-json",
|
||||||
"{{expectedJson}}",
|
"{{expectedJson}}",
|
||||||
"--excerpt",
|
"--excerpt",
|
||||||
@@ -132,6 +134,11 @@
|
|||||||
"title": "执行期结束",
|
"title": "执行期结束",
|
||||||
"display_type": "date"
|
"display_type": "date"
|
||||||
},
|
},
|
||||||
|
"approvedBudget": {
|
||||||
|
"type": "number",
|
||||||
|
"title": "立项经费(元)",
|
||||||
|
"description": "用于决算结余与预算符合性结论。可选。"
|
||||||
|
},
|
||||||
"expectedJson": {
|
"expectedJson": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"title": "预期指标 JSON",
|
"title": "预期指标 JSON",
|
||||||
@@ -318,9 +325,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "import-data",
|
"id": "import-projects",
|
||||||
"label": "批量导入数据",
|
"label": "批量导入项目",
|
||||||
"description": "粘贴 JSON 批量导入项目/成果/决算。",
|
"description": "粘贴结题项目 JSON 数组批量导入/更新(含预期指标)。",
|
||||||
"placements": [
|
"placements": [
|
||||||
"agent"
|
"agent"
|
||||||
],
|
],
|
||||||
@@ -328,14 +335,10 @@
|
|||||||
"interaction": "form",
|
"interaction": "form",
|
||||||
"entrypoint": {
|
"entrypoint": {
|
||||||
"type": "cli",
|
"type": "cli",
|
||||||
"command": "import",
|
"command": "import-projects",
|
||||||
"args": [
|
"args": [
|
||||||
"--projects-json",
|
"--projects-json",
|
||||||
"{{projectsJson}}",
|
"{{projectsJson}}"
|
||||||
"--achievements-json",
|
|
||||||
"{{achievementsJson}}",
|
|
||||||
"--expenses-json",
|
|
||||||
"{{expensesJson}}"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
@@ -344,37 +347,91 @@
|
|||||||
"projectsJson": {
|
"projectsJson": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"title": "项目 JSON",
|
"title": "项目 JSON",
|
||||||
"display_type": "textarea"
|
"description": "结题项目对象数组。",
|
||||||
},
|
|
||||||
"achievementsJson": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "成果 JSON",
|
|
||||||
"display_type": "textarea"
|
|
||||||
},
|
|
||||||
"expensesJson": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "决算 JSON",
|
|
||||||
"display_type": "textarea"
|
"display_type": "textarea"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"required": [
|
||||||
|
"projectsJson"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "list-data",
|
"id": "import-achievements",
|
||||||
"label": "查询台账",
|
"label": "批量导入成果",
|
||||||
"description": "查询结题项目/成果/决算。",
|
"description": "粘贴成果清单 JSON 数组批量导入(按项目编号关联)。",
|
||||||
|
"placements": [
|
||||||
|
"agent"
|
||||||
|
],
|
||||||
|
"executionProfile": "sync",
|
||||||
|
"interaction": "form",
|
||||||
|
"entrypoint": {
|
||||||
|
"type": "cli",
|
||||||
|
"command": "import-achievements",
|
||||||
|
"args": [
|
||||||
|
"--achievements-json",
|
||||||
|
"{{achievementsJson}}"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"achievementsJson": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "成果 JSON",
|
||||||
|
"description": "成果对象数组。",
|
||||||
|
"display_type": "textarea"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"achievementsJson"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "import-expenses",
|
||||||
|
"label": "批量导入决算",
|
||||||
|
"description": "粘贴决算明细 JSON 数组批量导入(科目/金额/日期)。",
|
||||||
|
"placements": [
|
||||||
|
"agent"
|
||||||
|
],
|
||||||
|
"executionProfile": "sync",
|
||||||
|
"interaction": "form",
|
||||||
|
"entrypoint": {
|
||||||
|
"type": "cli",
|
||||||
|
"command": "import-expenses",
|
||||||
|
"args": [
|
||||||
|
"--expenses-json",
|
||||||
|
"{{expensesJson}}"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"expensesJson": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "决算 JSON",
|
||||||
|
"description": "决算明细对象数组。",
|
||||||
|
"display_type": "textarea"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"expensesJson"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "list-projects",
|
||||||
|
"label": "查询结题项目",
|
||||||
|
"description": "按状态查询结题项目台账。",
|
||||||
"placements": [
|
"placements": [
|
||||||
"agent"
|
"agent"
|
||||||
],
|
],
|
||||||
"executionProfile": "sync",
|
"executionProfile": "sync",
|
||||||
"entrypoint": {
|
"entrypoint": {
|
||||||
"type": "cli",
|
"type": "cli",
|
||||||
"command": "list",
|
"command": "list-projects",
|
||||||
"args": [
|
"args": [
|
||||||
"--kind",
|
|
||||||
"{{kind}}",
|
|
||||||
"--project-no",
|
|
||||||
"{{projectNo}}",
|
|
||||||
"--status",
|
"--status",
|
||||||
"{{status}}"
|
"{{status}}"
|
||||||
]
|
]
|
||||||
@@ -382,19 +439,6 @@
|
|||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"kind": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "类型",
|
|
||||||
"enum": [
|
|
||||||
"projects",
|
|
||||||
"achievements",
|
|
||||||
"expenses"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"projectNo": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "项目编号"
|
|
||||||
},
|
|
||||||
"status": {
|
"status": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"title": "状态",
|
"title": "状态",
|
||||||
@@ -407,6 +451,66 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "list-achievements",
|
||||||
|
"label": "查询成果清单",
|
||||||
|
"description": "按项目编号查询成果清单。",
|
||||||
|
"placements": [
|
||||||
|
"agent"
|
||||||
|
],
|
||||||
|
"executionProfile": "sync",
|
||||||
|
"entrypoint": {
|
||||||
|
"type": "cli",
|
||||||
|
"command": "list-achievements",
|
||||||
|
"args": [
|
||||||
|
"--project-no",
|
||||||
|
"{{projectNo}}"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"projectNo": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "项目编号",
|
||||||
|
"description": "必填。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"projectNo"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "list-expenses",
|
||||||
|
"label": "查询决算明细",
|
||||||
|
"description": "按项目编号查询决算明细。",
|
||||||
|
"placements": [
|
||||||
|
"agent"
|
||||||
|
],
|
||||||
|
"executionProfile": "sync",
|
||||||
|
"entrypoint": {
|
||||||
|
"type": "cli",
|
||||||
|
"command": "list-expenses",
|
||||||
|
"args": [
|
||||||
|
"--project-no",
|
||||||
|
"{{projectNo}}"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"projectNo": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "项目编号",
|
||||||
|
"description": "必填。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"projectNo"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "list-drafts",
|
"id": "list-drafts",
|
||||||
"label": "查询初稿记录",
|
"label": "查询初稿记录",
|
||||||
@@ -436,7 +540,7 @@
|
|||||||
{
|
{
|
||||||
"id": "render-html",
|
"id": "render-html",
|
||||||
"label": "重建初稿预览",
|
"label": "重建初稿预览",
|
||||||
"description": "重建初稿 HTML 预览(缺省最近 10 份)。",
|
"description": "重建初稿 HTML 预览(选中初稿记录行时重建该份,未选行重建最近 10 份)。",
|
||||||
"placements": [
|
"placements": [
|
||||||
"toolbar",
|
"toolbar",
|
||||||
"skill-detail",
|
"skill-detail",
|
||||||
@@ -447,12 +551,29 @@
|
|||||||
"bind": {
|
"bind": {
|
||||||
"tables": [
|
"tables": [
|
||||||
"report_drafts"
|
"report_drafts"
|
||||||
]
|
],
|
||||||
|
"inputMapping": {
|
||||||
|
"id": "$row.$pk"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "number",
|
||||||
|
"title": "记录编号",
|
||||||
|
"description": "重建该份初稿预览。未选择行时留空,重建最近 10 份。",
|
||||||
|
"readOnly": true
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"entrypoint": {
|
"entrypoint": {
|
||||||
"type": "cli",
|
"type": "cli",
|
||||||
"command": "render-html",
|
"command": "render-html",
|
||||||
"args": []
|
"args": [
|
||||||
|
"--report-id",
|
||||||
|
"{{id}}"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"steps": [
|
"steps": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -19,9 +19,15 @@ from service.task_service import (
|
|||||||
cmd_draft_report,
|
cmd_draft_report,
|
||||||
cmd_health,
|
cmd_health,
|
||||||
cmd_import,
|
cmd_import,
|
||||||
|
cmd_import_achievements,
|
||||||
|
cmd_import_expenses,
|
||||||
|
cmd_import_projects,
|
||||||
cmd_init_db,
|
cmd_init_db,
|
||||||
cmd_list,
|
cmd_list,
|
||||||
|
cmd_list_achievements,
|
||||||
cmd_list_drafts,
|
cmd_list_drafts,
|
||||||
|
cmd_list_expenses,
|
||||||
|
cmd_list_projects,
|
||||||
cmd_render_html,
|
cmd_render_html,
|
||||||
cmd_run,
|
cmd_run,
|
||||||
cmd_show_draft,
|
cmd_show_draft,
|
||||||
@@ -51,6 +57,16 @@ def _cli_float_or_none(raw: Optional[str]) -> Optional[float]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _cli_int_or_none(raw: Optional[str]) -> Optional[int]:
|
||||||
|
raw = _cli_str_or_none(raw)
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(float(raw))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _handle_run(args: argparse.Namespace) -> int:
|
def _handle_run(args: argparse.Namespace) -> int:
|
||||||
tail = [str(x).strip() for x in (args.run_tail or []) if str(x).strip()]
|
tail = [str(x).strip() for x in (args.run_tail or []) if str(x).strip()]
|
||||||
if len(tail) > 2:
|
if len(tail) > 2:
|
||||||
@@ -80,6 +96,7 @@ def _handle_add_project(args: argparse.Namespace) -> int:
|
|||||||
program=_cli_str_or_none(args.program) or "",
|
program=_cli_str_or_none(args.program) or "",
|
||||||
start=_cli_str_or_none(args.start) or "",
|
start=_cli_str_or_none(args.start) or "",
|
||||||
end=_cli_str_or_none(args.end) or "",
|
end=_cli_str_or_none(args.end) or "",
|
||||||
|
approved_budget=_cli_str_or_none(args.approved_budget) or "",
|
||||||
expected_json=_cli_str_or_none(args.expected_json) or "",
|
expected_json=_cli_str_or_none(args.expected_json) or "",
|
||||||
excerpt=_cli_str_or_none(args.excerpt) or "",
|
excerpt=_cli_str_or_none(args.excerpt) or "",
|
||||||
status=_cli_str_or_none(args.status) or "",
|
status=_cli_str_or_none(args.status) or "",
|
||||||
@@ -103,18 +120,22 @@ def _handle_list(args: argparse.Namespace) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def _handle_render_html(args: argparse.Namespace) -> int:
|
def _handle_render_html(args: argparse.Namespace) -> int:
|
||||||
report_id = getattr(args, "report_id", None)
|
# 宿主行上下文可能给空串或未解析占位符(如 {{id}}):解析失败一律回退
|
||||||
return cmd_render_html(report_id=int(report_id) if report_id else None)
|
# 缺省行为(重建最近 10 份),不让 args 变体把渲染打崩。
|
||||||
|
report_id = _cli_int_or_none(getattr(args, "report_id", None))
|
||||||
|
return cmd_render_html(report_id=report_id)
|
||||||
|
|
||||||
|
|
||||||
def _print_full_usage() -> None:
|
def _print_full_usage() -> None:
|
||||||
print("结题报告初稿助手(main.py)可用命令:")
|
print("结题报告初稿助手(main.py)可用命令:")
|
||||||
print(" python main.py add-project --project-no X --name Y [--pi 张三] [--program 国自然青年] [--start D] [--end D]")
|
print(" python main.py add-project --project-no X --name Y [--pi 张三] [--program 国自然青年] [--start D] [--end D]")
|
||||||
print(" [--expected-json '[{\"type\":\"论文\",\"target\":3,\"unit\":\"篇\"}]'] [--excerpt 摘句] [--status 待结题]")
|
print(" [--approved-budget 500000] [--expected-json '[{\"type\":\"论文\",\"target\":3,\"unit\":\"篇\"}]'] [--excerpt 摘句] [--status 待结题]")
|
||||||
print(" python main.py add-achievement --project-no X --type 论文 --title T [--venue 期刊] [--date D] [--representative 1]")
|
print(" python main.py add-achievement --project-no X --type 论文 --title T [--venue 期刊] [--date D] [--representative 1]")
|
||||||
print(" python main.py add-expense --project-no X --category 差旅费 --amount 23000 [--date D] [--description 摘要]")
|
print(" python main.py add-expense --project-no X --category 差旅费 --amount 23000 [--date D] [--description 摘要]")
|
||||||
print(" python main.py import --projects-json F [--achievements-json F] [--expenses-json F]")
|
print(" python main.py import-projects --projects-json <file|-> | import-achievements --achievements-json <file|->")
|
||||||
print(" python main.py list [projects|achievements|expenses] [--project-no X] [--status 待结题]")
|
print(" | import-expenses --expenses-json <file|-> # 兼容别名:import --projects-json ...")
|
||||||
|
print(" python main.py list-projects [--status 待结题] | list-achievements --project-no X | list-expenses --project-no X")
|
||||||
|
print(" # 兼容别名:list [projects|achievements|expenses]")
|
||||||
print(" python main.py draft-report --project-no X # 主命令:生成 Word 初稿 + HTML 预览")
|
print(" python main.py draft-report --project-no X # 主命令:生成 Word 初稿 + HTML 预览")
|
||||||
print(" python main.py draft-batch-pending # 批量生成全部待结题初稿")
|
print(" python main.py draft-batch-pending # 批量生成全部待结题初稿")
|
||||||
print(" python main.py list-drafts [--limit N] | show-draft --id N | render-html [--report-id N]")
|
print(" python main.py list-drafts [--limit N] | show-draft --id N | render-html [--report-id N]")
|
||||||
@@ -139,6 +160,7 @@ def build_parser() -> ZhArgumentParser:
|
|||||||
sp.add_argument("--program", default=None, metavar="项目类型")
|
sp.add_argument("--program", default=None, metavar="项目类型")
|
||||||
sp.add_argument("--start", default=None, metavar="执行期开始")
|
sp.add_argument("--start", default=None, metavar="执行期开始")
|
||||||
sp.add_argument("--end", default=None, metavar="执行期结束")
|
sp.add_argument("--end", default=None, metavar="执行期结束")
|
||||||
|
sp.add_argument("--approved-budget", default=None, metavar="立项经费(元)", dest="approved_budget")
|
||||||
sp.add_argument("--expected-json", default=None, metavar="预期指标JSON", dest="expected_json")
|
sp.add_argument("--expected-json", default=None, metavar="预期指标JSON", dest="expected_json")
|
||||||
sp.add_argument("--excerpt", default=None, metavar="年度报告摘句")
|
sp.add_argument("--excerpt", default=None, metavar="年度报告摘句")
|
||||||
sp.add_argument("--status", default=None, metavar="状态(待结题/初稿已生成/已提交)")
|
sp.add_argument("--status", default=None, metavar="状态(待结题/初稿已生成/已提交)")
|
||||||
@@ -173,18 +195,42 @@ def build_parser() -> ZhArgumentParser:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
sp = sub.add_parser("import", help="批量导入(projects/achievements/expenses JSON)")
|
sp = sub.add_parser("import", help="批量导入(兼容别名:projects/achievements/expenses JSON)")
|
||||||
sp.add_argument("--projects-json", default=None, metavar="项目JSON(file|-)", dest="projects_json")
|
sp.add_argument("--projects-json", default=None, metavar="项目JSON(file|-)", dest="projects_json")
|
||||||
sp.add_argument("--achievements-json", default=None, metavar="成果JSON(file|-)", dest="achievements_json")
|
sp.add_argument("--achievements-json", default=None, metavar="成果JSON(file|-)", dest="achievements_json")
|
||||||
sp.add_argument("--expenses-json", default=None, metavar="决算JSON(file|-)", dest="expenses_json")
|
sp.add_argument("--expenses-json", default=None, metavar="决算JSON(file|-)", dest="expenses_json")
|
||||||
sp.set_defaults(handler=_handle_import)
|
sp.set_defaults(handler=_handle_import)
|
||||||
|
|
||||||
sp = sub.add_parser("list", help="查询台账(projects/achievements/expenses)")
|
sp = sub.add_parser("import-projects", help="批量导入结题项目")
|
||||||
|
sp.add_argument("--projects-json", required=True, metavar="JSON(file|-)", dest="projects_json")
|
||||||
|
sp.set_defaults(handler=lambda a: cmd_import_projects(str(a.projects_json)))
|
||||||
|
|
||||||
|
sp = sub.add_parser("import-achievements", help="批量导入成果清单")
|
||||||
|
sp.add_argument("--achievements-json", required=True, metavar="JSON(file|-)", dest="achievements_json")
|
||||||
|
sp.set_defaults(handler=lambda a: cmd_import_achievements(str(a.achievements_json)))
|
||||||
|
|
||||||
|
sp = sub.add_parser("import-expenses", help="批量导入决算明细")
|
||||||
|
sp.add_argument("--expenses-json", required=True, metavar="JSON(file|-)", dest="expenses_json")
|
||||||
|
sp.set_defaults(handler=lambda a: cmd_import_expenses(str(a.expenses_json)))
|
||||||
|
|
||||||
|
sp = sub.add_parser("list", help="查询台账(兼容别名:projects/achievements/expenses)")
|
||||||
sp.add_argument("--kind", default="projects", metavar="projects|achievements|expenses", dest="kind")
|
sp.add_argument("--kind", default="projects", metavar="projects|achievements|expenses", dest="kind")
|
||||||
sp.add_argument("--project-no", default=None, metavar="项目编号", dest="project_no")
|
sp.add_argument("--project-no", default=None, metavar="项目编号", dest="project_no")
|
||||||
sp.add_argument("--status", default=None, metavar="状态筛选")
|
sp.add_argument("--status", default=None, metavar="状态筛选")
|
||||||
sp.set_defaults(handler=_handle_list)
|
sp.set_defaults(handler=_handle_list)
|
||||||
|
|
||||||
|
sp = sub.add_parser("list-projects", help="查询结题项目")
|
||||||
|
sp.add_argument("--status", default=None, metavar="状态筛选")
|
||||||
|
sp.set_defaults(handler=lambda a: cmd_list_projects(status=_cli_str_or_none(a.status) or ""))
|
||||||
|
|
||||||
|
sp = sub.add_parser("list-achievements", help="查询成果清单")
|
||||||
|
sp.add_argument("--project-no", required=True, metavar="项目编号", dest="project_no")
|
||||||
|
sp.set_defaults(handler=lambda a: cmd_list_achievements(project_no=str(a.project_no)))
|
||||||
|
|
||||||
|
sp = sub.add_parser("list-expenses", help="查询决算明细")
|
||||||
|
sp.add_argument("--project-no", required=True, metavar="项目编号", dest="project_no")
|
||||||
|
sp.set_defaults(handler=lambda a: cmd_list_expenses(project_no=str(a.project_no)))
|
||||||
|
|
||||||
sp = sub.add_parser("draft-report", help="生成结题报告初稿(主命令)")
|
sp = sub.add_parser("draft-report", help="生成结题报告初稿(主命令)")
|
||||||
sp.add_argument("--project-no", required=True, metavar="项目编号", dest="project_no")
|
sp.add_argument("--project-no", required=True, metavar="项目编号", dest="project_no")
|
||||||
sp.set_defaults(handler=lambda a: cmd_draft_report(project_no=str(a.project_no)))
|
sp.set_defaults(handler=lambda a: cmd_draft_report(project_no=str(a.project_no)))
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ CREATE TABLE IF NOT EXISTS closing_projects (
|
|||||||
program TEXT,
|
program TEXT,
|
||||||
start_date TEXT,
|
start_date TEXT,
|
||||||
end_date TEXT,
|
end_date TEXT,
|
||||||
|
approved_budget REAL,
|
||||||
expected_json TEXT,
|
expected_json TEXT,
|
||||||
annual_report_excerpt TEXT,
|
annual_report_excerpt TEXT,
|
||||||
status TEXT NOT NULL DEFAULT '待结题',
|
status TEXT NOT NULL DEFAULT '待结题',
|
||||||
@@ -118,6 +119,7 @@ _CLOSING_PROJECTS_COLUMNS: tuple[dict[str, object], ...] = (
|
|||||||
_col("program", "项目类型", "如 国家自然科学基金(青年/面上)。", searchable=1, editable=1),
|
_col("program", "项目类型", "如 国家自然科学基金(青年/面上)。", searchable=1, editable=1),
|
||||||
_col("start_date", "执行期开始", "YYYY-MM-DD。", searchable=1, editable=1),
|
_col("start_date", "执行期开始", "YYYY-MM-DD。", searchable=1, editable=1),
|
||||||
_col("end_date", "执行期结束", "YYYY-MM-DD。", searchable=1, editable=1),
|
_col("end_date", "执行期结束", "YYYY-MM-DD。", searchable=1, editable=1),
|
||||||
|
_col("approved_budget", "立项经费", "立项批准经费(元);用于决算结余与预算符合性结论。", editable=1, display_type="number"),
|
||||||
_col("expected_json", "预期成果指标", "如 [{\"type\":\"论文\",\"target\":3,\"unit\":\"篇\"}]。", visible=0, display_type="textarea", editable=1),
|
_col("expected_json", "预期成果指标", "如 [{\"type\":\"论文\",\"target\":3,\"unit\":\"篇\"}]。", visible=0, display_type="textarea", editable=1),
|
||||||
_col("annual_report_excerpt", "年度报告摘句", "年度/中期报告关键句(分号分隔),供叙事素材。", visible=0, display_type="textarea", editable=1),
|
_col("annual_report_excerpt", "年度报告摘句", "年度/中期报告关键句(分号分隔),供叙事素材。", visible=0, display_type="textarea", editable=1),
|
||||||
_col("status", "状态", "待结题 / 初稿已生成 / 已提交。", searchable=1, editable=1, options_json='[{"value":"待结题","label":"待结题"},{"value":"初稿已生成","label":"初稿已生成"},{"value":"已提交","label":"已提交"}]'),
|
_col("status", "状态", "待结题 / 初稿已生成 / 已提交。", searchable=1, editable=1, options_json='[{"value":"待结题","label":"待结题"},{"value":"初稿已生成","label":"初稿已生成"},{"value":"已提交","label":"已提交"}]'),
|
||||||
@@ -162,8 +164,18 @@ _REPORT_DRAFTS_COLUMNS: tuple[dict[str, object], ...] = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_columns(cursor, table_name: str, columns: tuple[tuple[str, str], ...]) -> None:
|
||||||
|
"""幂等迁移:老库缺列时 ALTER TABLE 补列(CREATE TABLE IF NOT EXISTS 不会改已有表)。"""
|
||||||
|
cursor.execute(f"PRAGMA table_info({table_name})")
|
||||||
|
existing = {str(row[1]) for row in cursor.fetchall()}
|
||||||
|
for column_name, column_ddl in columns:
|
||||||
|
if column_name not in existing:
|
||||||
|
cursor.execute(f"ALTER TABLE {table_name} ADD COLUMN {column_ddl}")
|
||||||
|
|
||||||
|
|
||||||
def _init_closing_projects_metadata(cursor) -> None:
|
def _init_closing_projects_metadata(cursor) -> None:
|
||||||
cursor.execute(_CLOSING_PROJECTS_DDL)
|
cursor.execute(_CLOSING_PROJECTS_DDL)
|
||||||
|
_ensure_columns(cursor, "closing_projects", (("approved_budget", "approved_budget REAL"),))
|
||||||
init_updated_at_trigger(cursor, "closing_projects")
|
init_updated_at_trigger(cursor, "closing_projects")
|
||||||
upsert_table_metadata(
|
upsert_table_metadata(
|
||||||
cursor,
|
cursor,
|
||||||
|
|||||||
@@ -32,13 +32,15 @@ def write_docx(project: dict[str, Any], sections: dict[str, list[str]], batch_no
|
|||||||
"""生成《结题报告(初稿)》docx,返回文件路径。"""
|
"""生成《结题报告(初稿)》docx,返回文件路径。"""
|
||||||
_check_docx_available()
|
_check_docx_available()
|
||||||
import docx
|
import docx
|
||||||
|
from docx.enum.text import WD_COLOR_INDEX
|
||||||
from docx.shared import Pt
|
from docx.shared import Pt
|
||||||
|
|
||||||
log = _log()
|
|
||||||
log.info("docx_render_start project_no=%s stage=docx", project.get("project_no"))
|
|
||||||
|
|
||||||
document = docx.Document()
|
document = docx.Document()
|
||||||
|
|
||||||
|
log = _log()
|
||||||
|
if log:
|
||||||
|
log.info("docx_render_start project_no=%s stage=docx", project.get("project_no"))
|
||||||
|
|
||||||
title = document.add_heading("结题报告(初稿)", level=0)
|
title = document.add_heading("结题报告(初稿)", level=0)
|
||||||
title.alignment = 1
|
title.alignment = 1
|
||||||
|
|
||||||
@@ -68,8 +70,8 @@ def write_docx(project: dict[str, Any], sections: dict[str, list[str]], batch_no
|
|||||||
p = document.add_paragraph()
|
p = document.add_paragraph()
|
||||||
run = p.add_run(text)
|
run = p.add_run(text)
|
||||||
if text.startswith("[待补充"):
|
if text.startswith("[待补充"):
|
||||||
run.font.color.rgb = None # 占位文本以提示语区分,不依赖颜色
|
|
||||||
run.italic = True
|
run.italic = True
|
||||||
|
run.font.highlight_color = WD_COLOR_INDEX.YELLOW # 占位黄底提示,便于教师定位待写章节
|
||||||
|
|
||||||
notice = document.add_paragraph()
|
notice = document.add_paragraph()
|
||||||
notice_run = notice.add_run(
|
notice_run = notice.add_run(
|
||||||
@@ -82,5 +84,6 @@ def write_docx(project: dict[str, Any], sections: dict[str, list[str]], batch_no
|
|||||||
filename = f"结题报告初稿-{project_no}.docx"
|
filename = f"结题报告初稿-{project_no}.docx"
|
||||||
path = get_output_dir() + "/" + filename
|
path = get_output_dir() + "/" + filename
|
||||||
document.save(path)
|
document.save(path)
|
||||||
log.info("docx_render_done project_no=%s path=%s stage=docx", project_no, path)
|
if log:
|
||||||
|
log.info("docx_render_done project_no=%s path=%s stage=docx", project_no, path)
|
||||||
return path
|
return path
|
||||||
|
|||||||
@@ -60,6 +60,15 @@ def _camel(name: str) -> str:
|
|||||||
return head + "".join(p[:1].upper() + p[1:] for p in rest if p)
|
return head + "".join(p[:1].upper() + p[1:] for p in rest if p)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_float(value: Any) -> Optional[float]:
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _snake(name: str) -> str:
|
def _snake(name: str) -> str:
|
||||||
return "".join("_" + ch.lower() if ch.isupper() else ch for ch in str(name)).lstrip("_")
|
return "".join("_" + ch.lower() if ch.isupper() else ch for ch in str(name)).lstrip("_")
|
||||||
|
|
||||||
@@ -73,6 +82,7 @@ def _norm_project(raw: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"program": ("program", "项目类型", "资助渠道"),
|
"program": ("program", "项目类型", "资助渠道"),
|
||||||
"startDate": ("start_date", "执行期开始", "开始日期"),
|
"startDate": ("start_date", "执行期开始", "开始日期"),
|
||||||
"endDate": ("end_date", "执行期结束", "结束日期"),
|
"endDate": ("end_date", "执行期结束", "结束日期"),
|
||||||
|
"approvedBudget": ("approved_budget", "立项经费", "批准经费"),
|
||||||
"expected": ("expected_json", "预期成果指标", "预期指标"),
|
"expected": ("expected_json", "预期成果指标", "预期指标"),
|
||||||
"annualReportExcerpt": ("annual_report_excerpt", "年度报告摘句", "年度报告"),
|
"annualReportExcerpt": ("annual_report_excerpt", "年度报告摘句", "年度报告"),
|
||||||
"status": ("status", "状态"),
|
"status": ("status", "状态"),
|
||||||
@@ -123,11 +133,16 @@ def _norm_expense(raw: dict[str, Any]) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def _c1_compare(expected: list[dict[str, Any]], achievements: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
def _c1_compare(expected: list[dict[str, Any]], achievements: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
"""C1:按类型对比预期指标与实际成果。"""
|
"""C1:按类型对比预期指标与实际成果。
|
||||||
|
|
||||||
|
成果台账整表为空时无从对比,一律判"数据待补"(不下"未达标"结论);
|
||||||
|
台账非空但某类型无记录按实际 0 计(事实陈述)。
|
||||||
|
"""
|
||||||
counts: dict[str, int] = {}
|
counts: dict[str, int] = {}
|
||||||
for ach in achievements:
|
for ach in achievements:
|
||||||
ach_type = str(ach.get("achievement_type") or "").strip()
|
ach_type = str(ach.get("achievement_type") or "").strip()
|
||||||
counts[ach_type] = counts.get(ach_type, 0) + 1
|
counts[ach_type] = counts.get(ach_type, 0) + 1
|
||||||
|
ledger_empty = not achievements
|
||||||
rows = []
|
rows = []
|
||||||
for item in expected:
|
for item in expected:
|
||||||
item_type = str(item.get("type") or "").strip()
|
item_type = str(item.get("type") or "").strip()
|
||||||
@@ -140,8 +155,11 @@ def _c1_compare(expected: list[dict[str, Any]], achievements: list[dict[str, Any
|
|||||||
actual = counts.get(item_type)
|
actual = counts.get(item_type)
|
||||||
if target_v is None:
|
if target_v is None:
|
||||||
verdict, detail = "数据待补", f"预期指标未配置数值(类型:{item_type})"
|
verdict, detail = "数据待补", f"预期指标未配置数值(类型:{item_type})"
|
||||||
|
elif ledger_empty:
|
||||||
|
verdict, detail = "数据待补", f"预期 {target_v} {unit},成果清单尚未录入,无法对比(W1)"
|
||||||
elif actual is None:
|
elif actual is None:
|
||||||
verdict, detail = "未达标", f"预期 {target_v} {unit},台账中未录入该类成果"
|
verdict, detail = "未达标", f"预期 {target_v} {unit},实际 0 {unit}(台账中未录入该类成果)"
|
||||||
|
actual = 0
|
||||||
else:
|
else:
|
||||||
if actual > target_v:
|
if actual > target_v:
|
||||||
verdict = "超额完成"
|
verdict = "超额完成"
|
||||||
@@ -177,7 +195,11 @@ def _c3_expense_sentences(expenses: list[dict[str, Any]], total_approved: Option
|
|||||||
return [], "经费决算明细尚未录入,请在补录后重新生成初稿(W1)。"
|
return [], "经费决算明细尚未录入,请在补录后重新生成初稿(W1)。"
|
||||||
tail = f"。项目决算合计支出 {_wan(total)}。"
|
tail = f"。项目决算合计支出 {_wan(total)}。"
|
||||||
if total_approved is not None:
|
if total_approved is not None:
|
||||||
tail += "支出总额控制在立项经费范围内。" if total <= total_approved else "支出总额超出立项经费,请补充说明。"
|
balance = round(total_approved - total, 2)
|
||||||
|
if balance >= 0:
|
||||||
|
tail += f"结余 {_wan(balance)},支出总额控制在立项经费({_wan(total_approved)})范围内。"
|
||||||
|
else:
|
||||||
|
tail += f"超出立项经费({_wan(total_approved)}){_wan(-balance)},请补充说明。"
|
||||||
return rows, "本项目经费支出按科目如下:" + ";".join(sentences) + "。" + tail
|
return rows, "本项目经费支出按科目如下:" + ";".join(sentences) + "。" + tail
|
||||||
|
|
||||||
|
|
||||||
@@ -224,7 +246,8 @@ def build_draft_sections(
|
|||||||
expenses = [{_snake(k): v for k, v in _norm_expense(e).items()} for e in expenses]
|
expenses = [{_snake(k): v for k, v in _norm_expense(e).items()} for e in expenses]
|
||||||
|
|
||||||
completion = _c1_compare(expected, achievements)
|
completion = _c1_compare(expected, achievements)
|
||||||
expense_rows, expense_paragraph = _c3_expense_sentences(expenses, None)
|
approved_budget = _to_float(project.get("approved_budget") or project.get("approvedBudget"))
|
||||||
|
expense_rows, expense_paragraph = _c3_expense_sentences(expenses, approved_budget)
|
||||||
excerpt = project.get("annual_report_excerpt") or project.get("annualReportExcerpt") or ""
|
excerpt = project.get("annual_report_excerpt") or project.get("annualReportExcerpt") or ""
|
||||||
narrative_points = _c4_narrative_points(achievements, str(excerpt))
|
narrative_points = _c4_narrative_points(achievements, str(excerpt))
|
||||||
|
|
||||||
@@ -278,16 +301,6 @@ def run_draft(project_raw: dict[str, Any], achievements: list[dict[str, Any]], e
|
|||||||
|
|
||||||
draft_file = write_docx(project, sections, batch_no)
|
draft_file = write_docx(project, sections, batch_no)
|
||||||
|
|
||||||
draft = {
|
|
||||||
"project_no": project_no,
|
|
||||||
"project_name": str(project.get("project_name") or ""),
|
|
||||||
"batch_no": batch_no,
|
|
||||||
"sections": sections,
|
|
||||||
"completion": sections_data["completion"],
|
|
||||||
"narrative_points": sections_data["narrative_points"],
|
|
||||||
}
|
|
||||||
preview_file = write_preview_for_kernel(draft=draft)
|
|
||||||
|
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
try:
|
try:
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
@@ -295,7 +308,7 @@ def run_draft(project_raw: dict[str, Any], achievements: list[dict[str, Any]], e
|
|||||||
"""
|
"""
|
||||||
INSERT INTO report_drafts
|
INSERT INTO report_drafts
|
||||||
(batch_no, project_no, project_name, completion_json, narrative_points_json, draft_file, preview_file)
|
(batch_no, project_no, project_name, completion_json, narrative_points_json, draft_file, preview_file)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, '')
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
batch_no,
|
batch_no,
|
||||||
@@ -304,10 +317,20 @@ def run_draft(project_raw: dict[str, Any], achievements: list[dict[str, Any]], e
|
|||||||
json.dumps(sections_data["completion"], ensure_ascii=False),
|
json.dumps(sections_data["completion"], ensure_ascii=False),
|
||||||
json.dumps(sections_data["narrative_points"], ensure_ascii=False),
|
json.dumps(sections_data["narrative_points"], ensure_ascii=False),
|
||||||
draft_file,
|
draft_file,
|
||||||
preview_file,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
draft_id = int(cur.lastrowid)
|
draft_id = int(cur.lastrowid)
|
||||||
|
# 预览按初稿记录编号命名(draft-{id}.html),多次生成互不覆盖。
|
||||||
|
draft = {
|
||||||
|
"project_no": project_no,
|
||||||
|
"project_name": str(project.get("project_name") or ""),
|
||||||
|
"batch_no": batch_no,
|
||||||
|
"sections": sections,
|
||||||
|
"completion": sections_data["completion"],
|
||||||
|
"narrative_points": sections_data["narrative_points"],
|
||||||
|
}
|
||||||
|
preview_file = write_preview_for_kernel(draft=draft, draft_id=draft_id)
|
||||||
|
cur.execute("UPDATE report_drafts SET preview_file = ? WHERE id = ?", (preview_file, draft_id))
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"UPDATE closing_projects SET status = '初稿已生成', updated_at = ? WHERE project_no = ?",
|
"UPDATE closing_projects SET status = '初稿已生成', updated_at = ? WHERE project_no = ?",
|
||||||
(now_unix(), project_no),
|
(now_unix(), project_no),
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ def upsert_project(fields: dict[str, Any]) -> dict[str, Any]:
|
|||||||
values[key] = str(fields[key]).strip()
|
values[key] = str(fields[key]).strip()
|
||||||
if values.get("status") and values["status"] not in _KNOWN_STATUS:
|
if values.get("status") and values["status"] not in _KNOWN_STATUS:
|
||||||
raise ValueError(f"status 只接受 {'、'.join(_KNOWN_STATUS)}:{values['status']}")
|
raise ValueError(f"status 只接受 {'、'.join(_KNOWN_STATUS)}:{values['status']}")
|
||||||
|
if fields.get("approved_budget") not in (None, ""):
|
||||||
|
approved = _to_float(fields.get("approved_budget"))
|
||||||
|
if approved is None or approved < 0:
|
||||||
|
raise ValueError(f"立项经费必须是非负数字(元):{fields.get('approved_budget')}")
|
||||||
|
values["approved_budget"] = round(approved, 2)
|
||||||
if fields.get("expected_json") not in (None, ""):
|
if fields.get("expected_json") not in (None, ""):
|
||||||
raw = fields["expected_json"]
|
raw = fields["expected_json"]
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
"""初稿 HTML 预览组装。
|
"""初稿 HTML 预览组装。
|
||||||
|
|
||||||
与 Word 初稿同构内容写入 `{skill_data}/html/draft-{id}.html`,宿主侧栏可预览;
|
与 Word 初稿同构内容写入 `{skill_data}/html/draft-{id}.html`,宿主侧栏可预览;
|
||||||
重建走固定 Action id `render-html`。
|
重建走固定 Action id `render-html`(按台账全量重建,与生成时同一内核)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from service.html_page_service import load_html_template, render_html_placeholders, write_html_page
|
from service.html_page_service import load_html_template, render_html_placeholders, write_html_page
|
||||||
@@ -15,6 +14,13 @@ from util.logging_config import get_skill_logger
|
|||||||
_TEMPLATE_REL = "assets/html/draft-preview.html"
|
_TEMPLATE_REL = "assets/html/draft-preview.html"
|
||||||
|
|
||||||
|
|
||||||
|
def _log():
|
||||||
|
try:
|
||||||
|
return get_skill_logger()
|
||||||
|
except RuntimeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _esc(value: Any) -> str:
|
def _esc(value: Any) -> str:
|
||||||
import html
|
import html
|
||||||
|
|
||||||
@@ -52,20 +58,25 @@ def build_preview_html(draft: dict[str, Any]) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def write_preview_for_kernel(*, draft: dict[str, Any]) -> str:
|
def write_preview_for_kernel(*, draft: dict[str, Any], draft_id: int) -> str:
|
||||||
|
"""生成期写预览:按初稿记录编号命名,多次生成互不覆盖。"""
|
||||||
html_text = build_preview_html(draft)
|
html_text = build_preview_html(draft)
|
||||||
log = get_skill_logger()
|
log = _log()
|
||||||
path = write_html_page("draft-preview.html", html_text)
|
path = write_html_page(f"draft-{draft_id}.html", html_text)
|
||||||
log.info("draft_preview_written project_no=%s path=%s stage=report", draft.get("project_no"), path)
|
if log:
|
||||||
|
log.info("draft_preview_written draft_id=%s project_no=%s path=%s stage=report", draft_id, draft.get("project_no"), path)
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
def build_preview_for_draft_id(draft_id: int) -> str:
|
def build_preview_for_draft_id(draft_id: int) -> str:
|
||||||
"""按初稿记录 id 重建 HTML 预览(重建入口用)。"""
|
"""按初稿记录 id 从台账全量重建 HTML 预览(与 Word 初稿同构,含决算/成果/占位章节)。"""
|
||||||
from db.connection import get_conn
|
from db.connection import get_conn
|
||||||
|
from service.draft_service import build_draft_sections, query_pending_projects
|
||||||
|
from service.ledger_service import list_achievements, list_expenses
|
||||||
|
|
||||||
log = get_skill_logger()
|
log = _log()
|
||||||
log.info("preview_render_start draft_id=%s stage=render", draft_id)
|
if log:
|
||||||
|
log.info("preview_render_start draft_id=%s stage=render", draft_id)
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
try:
|
try:
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
@@ -78,27 +89,25 @@ def build_preview_for_draft_id(draft_id: int) -> str:
|
|||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
try:
|
project_no = str(record.get("project_no") or "")
|
||||||
completion = json.loads(record.get("completion_json") or "[]")
|
projects = query_pending_projects(project_no=project_no)
|
||||||
except (TypeError, ValueError):
|
project = projects[0] if projects else {
|
||||||
completion = []
|
"project_no": project_no,
|
||||||
try:
|
"project_name": str(record.get("project_name") or ""),
|
||||||
narrative_points = json.loads(record.get("narrative_points_json") or "[]")
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
narrative_points = []
|
|
||||||
|
|
||||||
sections: dict[str, list[str]] = {
|
|
||||||
"完成情况(自动生成)": [f"{row.get('type')}:{row.get('detail')}" for row in completion] or ["指标未配置(W1)。"],
|
|
||||||
"叙事素材要点": [f"· {p.get('point')}(来源:{p.get('source')})" for p in narrative_points] or ["无素材,请补录成果/年度报告摘句。"],
|
|
||||||
"Word 初稿": [f"文件:{record.get('draft_file') or '—'}(output 目录可取)"],
|
|
||||||
}
|
}
|
||||||
|
sections_data = build_draft_sections(
|
||||||
|
project,
|
||||||
|
list_achievements(project_no),
|
||||||
|
list_expenses(project_no),
|
||||||
|
)
|
||||||
draft = {
|
draft = {
|
||||||
"project_no": record.get("project_no"),
|
"project_no": project_no,
|
||||||
"project_name": record.get("project_name"),
|
"project_name": str(project.get("project_name") or record.get("project_name") or ""),
|
||||||
"batch_no": record.get("batch_no"),
|
"batch_no": str(record.get("batch_no") or ""),
|
||||||
"sections": sections,
|
"sections": sections_data["sections"],
|
||||||
}
|
}
|
||||||
html_text = build_preview_html(draft)
|
html_text = build_preview_html(draft)
|
||||||
path = write_html_page(f"draft-{draft_id}.html", html_text)
|
path = write_html_page(f"draft-{draft_id}.html", html_text)
|
||||||
log.info("preview_render_done draft_id=%s path=%s stage=render", draft_id, path)
|
if log:
|
||||||
|
log.info("preview_render_done draft_id=%s path=%s stage=render", draft_id, path)
|
||||||
return path
|
return path
|
||||||
|
|||||||
@@ -260,6 +260,7 @@ def cmd_add_project(
|
|||||||
program: str = "",
|
program: str = "",
|
||||||
start: str = "",
|
start: str = "",
|
||||||
end: str = "",
|
end: str = "",
|
||||||
|
approved_budget: str = "",
|
||||||
expected_json: str = "",
|
expected_json: str = "",
|
||||||
excerpt: str = "",
|
excerpt: str = "",
|
||||||
status: str = "",
|
status: str = "",
|
||||||
@@ -278,7 +279,7 @@ def cmd_add_project(
|
|||||||
print(f"ERROR:ENTITLEMENT_DENIED {reason}")
|
print(f"ERROR:ENTITLEMENT_DENIED {reason}")
|
||||||
return 1
|
return 1
|
||||||
fields: dict[str, Any] = {"project_no": project_no}
|
fields: dict[str, Any] = {"project_no": project_no}
|
||||||
for key, value in (("project_name", name), ("pi_name", pi), ("department", department), ("program", program), ("start_date", start), ("end_date", end), ("expected_json", expected_json), ("annual_report_excerpt", excerpt), ("status", status)):
|
for key, value in (("project_name", name), ("pi_name", pi), ("department", department), ("program", program), ("start_date", start), ("end_date", end), ("approved_budget", approved_budget), ("expected_json", expected_json), ("annual_report_excerpt", excerpt), ("status", status)):
|
||||||
if value:
|
if value:
|
||||||
fields[key] = value
|
fields[key] = value
|
||||||
payload = upsert_project(fields)
|
payload = upsert_project(fields)
|
||||||
@@ -411,6 +412,86 @@ def cmd_import(projects_json: str = "", achievements_json: str = "", expenses_js
|
|||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_import_one(label: str, raw_json: str, import_fn) -> int:
|
||||||
|
"""单一台账批量导入(import-projects / import-achievements / import-expenses 共用)。"""
|
||||||
|
log = _get_task_logger()
|
||||||
|
with job_context(skill=SKILL_SLUG):
|
||||||
|
log.info("task_start task_type=import_%s stage=ledger", label)
|
||||||
|
emit(f"开始批量导入{label}", skill=SKILL_SLUG, stage="ledger")
|
||||||
|
try:
|
||||||
|
ok, reason = check_entitlement(SKILL_SLUG)
|
||||||
|
if not ok:
|
||||||
|
log.warning("entitlement_failed task_type=import_%s stage=auth", label)
|
||||||
|
finish(status="failed", message=reason, skill=SKILL_SLUG, error_code="ENTITLEMENT_DENIED", stage="auth")
|
||||||
|
print(f"ERROR:ENTITLEMENT_DENIED {reason}")
|
||||||
|
return 1
|
||||||
|
payload = import_fn(_parse_json_arg(raw_json, f"--{label}-json"))
|
||||||
|
_print_json(payload)
|
||||||
|
finish(status="success", message=f"导入完成:新增 {payload['created']},更新 {payload['updated']}", skill=SKILL_SLUG, stage="ledger")
|
||||||
|
return 0
|
||||||
|
except ValueError as exc:
|
||||||
|
log.warning("task_failed task_type=import_%s error_code=PROJECT_INVALID detail=%s", label, exc)
|
||||||
|
finish(status="failed", message=str(exc), skill=SKILL_SLUG, error_code="PROJECT_INVALID", stage="ledger")
|
||||||
|
print(f"ERROR:PROJECT_INVALID {exc}")
|
||||||
|
return 1
|
||||||
|
except JobStopped:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
log.exception("task_failed task_type=import_%s stage=ledger", label)
|
||||||
|
finish(status="failed", message="批量导入异常,详见统一日志", skill=SKILL_SLUG, stage="ledger")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_import_projects(projects_json: str) -> int:
|
||||||
|
from service.ledger_service import import_projects
|
||||||
|
|
||||||
|
return _cmd_import_one("projects", projects_json, import_projects)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_import_achievements(achievements_json: str) -> int:
|
||||||
|
from service.ledger_service import import_achievements
|
||||||
|
|
||||||
|
return _cmd_import_one("achievements", achievements_json, import_achievements)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_import_expenses(expenses_json: str) -> int:
|
||||||
|
from service.ledger_service import import_expenses
|
||||||
|
|
||||||
|
return _cmd_import_one("expenses", expenses_json, import_expenses)
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_list_one(label: str, fetch) -> int:
|
||||||
|
log = _get_task_logger()
|
||||||
|
with job_context(skill=SKILL_SLUG):
|
||||||
|
log.info("task_start task_type=list_%s", label)
|
||||||
|
try:
|
||||||
|
rows = fetch()
|
||||||
|
_print_json({"ok": True, "count": len(rows), "rows": rows})
|
||||||
|
return 0
|
||||||
|
except Exception:
|
||||||
|
log.exception("task_failed task_type=list_%s", label)
|
||||||
|
print("ERROR:QUERY_FAILED 台账查询异常,详见统一日志")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_list_projects(status: str = "") -> int:
|
||||||
|
from service.ledger_service import list_projects
|
||||||
|
|
||||||
|
return _cmd_list_one("projects", lambda: list_projects(status=status))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_list_achievements(project_no: str) -> int:
|
||||||
|
from service.ledger_service import list_achievements
|
||||||
|
|
||||||
|
return _cmd_list_one("achievements", lambda: list_achievements(project_no))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_list_expenses(project_no: str) -> int:
|
||||||
|
from service.ledger_service import list_expenses
|
||||||
|
|
||||||
|
return _cmd_list_one("expenses", lambda: list_expenses(project_no))
|
||||||
|
|
||||||
|
|
||||||
def cmd_list(kind: str = "projects", project_no: str = "", status: str = "") -> int:
|
def cmd_list(kind: str = "projects", project_no: str = "", status: str = "") -> int:
|
||||||
log = _get_task_logger()
|
log = _get_task_logger()
|
||||||
from service.ledger_service import list_achievements, list_expenses, list_projects
|
from service.ledger_service import list_achievements, list_expenses, list_projects
|
||||||
|
|||||||
@@ -92,6 +92,21 @@ class TestC1Completion(DraftKernelTestBase):
|
|||||||
result = self._kernel(expected=[{"type": "论文", "unit": "篇"}])
|
result = self._kernel(expected=[{"type": "论文", "unit": "篇"}])
|
||||||
self.assertEqual(result["completion"][0]["verdict"], "数据待补")
|
self.assertEqual(result["completion"][0]["verdict"], "数据待补")
|
||||||
|
|
||||||
|
def test_empty_ledger_is_pending_not_unmet(self) -> None:
|
||||||
|
"""成果台账整表为空 → 全部"数据待补",不下"未达标"结论(C1/W1)。"""
|
||||||
|
result = self._kernel(achievements=[])
|
||||||
|
for row in result["completion"]:
|
||||||
|
self.assertEqual(row["verdict"], "数据待补")
|
||||||
|
self.assertIn("尚未录入", row["detail"])
|
||||||
|
|
||||||
|
def test_present_ledger_missing_type_is_unmet_zero(self) -> None:
|
||||||
|
"""台账非空但某类型无记录 → 实际按 0 计,未达标(事实陈述)。"""
|
||||||
|
result = self._kernel(achievements=[ach for ach in _achievements() if ach["achievementType"] != "专利"])
|
||||||
|
by_type = {row["type"]: row for row in result["completion"]}
|
||||||
|
self.assertEqual(by_type["专利"]["verdict"], "未达标")
|
||||||
|
self.assertEqual(by_type["专利"]["actual"], 0)
|
||||||
|
self.assertIn("实际 0 项", by_type["专利"]["detail"])
|
||||||
|
|
||||||
|
|
||||||
class TestC3Expenses(DraftKernelTestBase):
|
class TestC3Expenses(DraftKernelTestBase):
|
||||||
def test_ratio_sums_to_one_and_sentences(self) -> None:
|
def test_ratio_sums_to_one_and_sentences(self) -> None:
|
||||||
@@ -106,6 +121,27 @@ class TestC3Expenses(DraftKernelTestBase):
|
|||||||
result = self._kernel(expenses=[])
|
result = self._kernel(expenses=[])
|
||||||
self.assertIn("尚未录入", result["expense_paragraph"])
|
self.assertIn("尚未录入", result["expense_paragraph"])
|
||||||
|
|
||||||
|
def test_balance_within_approved_budget(self) -> None:
|
||||||
|
# 台账支出 273000 元,立项经费 500000 元 → 结余 22.70 万元
|
||||||
|
result = self._kernel()
|
||||||
|
total = round(sum(row["amount"] for row in result["expense_rows"]), 2)
|
||||||
|
self.assertEqual(total, 273000.0)
|
||||||
|
with_approved = self._kernel_with_approved(500000)
|
||||||
|
self.assertIn("合计支出 27.30 万元", with_approved)
|
||||||
|
self.assertIn("结余 22.70 万元", with_approved)
|
||||||
|
self.assertIn("范围内", with_approved)
|
||||||
|
|
||||||
|
def test_overspend_flags_exceed(self) -> None:
|
||||||
|
paragraph = self._kernel_with_approved(200000)
|
||||||
|
self.assertIn("超出立项经费", paragraph)
|
||||||
|
self.assertIn("7.30 万元", paragraph)
|
||||||
|
|
||||||
|
def _kernel_with_approved(self, approved: float) -> str:
|
||||||
|
from service.draft_service import build_draft_sections
|
||||||
|
|
||||||
|
project = _project(approvedBudget=approved)
|
||||||
|
return build_draft_sections(project, _achievements(), _expenses())["expense_paragraph"]
|
||||||
|
|
||||||
|
|
||||||
class TestC4AndC5(DraftKernelTestBase):
|
class TestC4AndC5(DraftKernelTestBase):
|
||||||
def test_narrative_points_with_source(self) -> None:
|
def test_narrative_points_with_source(self) -> None:
|
||||||
@@ -166,15 +202,42 @@ class TestDraftGeneration(unittest.TestCase):
|
|||||||
self.assertIn("经费决算说明", xml)
|
self.assertIn("经费决算说明", xml)
|
||||||
self.assertIn("来源:成果清单", xml)
|
self.assertIn("来源:成果清单", xml)
|
||||||
self.assertIn("待补充", xml)
|
self.assertIn("待补充", xml)
|
||||||
|
self.assertIn('w:highlight w:val="yellow"', xml) # [待补充] 占位黄底提示
|
||||||
|
|
||||||
def test_preview_and_status_update(self) -> None:
|
def test_preview_named_by_draft_id_and_status_update(self) -> None:
|
||||||
result = self._draft()
|
result = self._draft()
|
||||||
self.assertTrue(result["preview_file"].endswith(".html"))
|
self.assertTrue(result["preview_file"].endswith("draft-1.html"))
|
||||||
from service.ledger_service import list_projects
|
from service.ledger_service import list_projects
|
||||||
|
|
||||||
row = next(p for p in list_projects() if p["project_no"] == "NSFC2026003")
|
row = next(p for p in list_projects() if p["project_no"] == "NSFC2026003")
|
||||||
self.assertEqual(row["status"], "初稿已生成")
|
self.assertEqual(row["status"], "初稿已生成")
|
||||||
|
|
||||||
|
def test_second_draft_preview_not_overwritten(self) -> None:
|
||||||
|
from service.ledger_service import upsert_project
|
||||||
|
from service.draft_service import query_pending_projects, run_draft
|
||||||
|
|
||||||
|
upsert_project(_project(projectNo="NSFC2026004", projectName="第二个项目"))
|
||||||
|
result2 = run_draft(query_pending_projects(project_no="NSFC2026004")[0], [], [])
|
||||||
|
result1 = run_draft(query_pending_projects(project_no="NSFC2026003")[0], [], [])
|
||||||
|
self.assertIn("draft-1.html", result2["preview_file"])
|
||||||
|
self.assertIn("draft-2.html", result1["preview_file"])
|
||||||
|
self.assertNotEqual(result1["preview_file"], result2["preview_file"])
|
||||||
|
|
||||||
|
def test_rebuild_preview_is_full_sections(self) -> None:
|
||||||
|
from service.report_service import build_preview_for_draft_id
|
||||||
|
|
||||||
|
result = self._draft()
|
||||||
|
draft_id = result["draft_id"]
|
||||||
|
path = build_preview_for_draft_id(draft_id)
|
||||||
|
self.assertIn(f"draft-{draft_id}.html", path)
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
html_text = f.read()
|
||||||
|
# 与 Word 初稿同构:决算、成果清单、占位指引齐全(而非降级的三节摘要)
|
||||||
|
self.assertIn("经费决算说明", html_text)
|
||||||
|
self.assertIn("成果清单", html_text)
|
||||||
|
self.assertIn("研究背景与目标", html_text)
|
||||||
|
self.assertIn("待补充", html_text)
|
||||||
|
|
||||||
def test_batch_pending(self) -> None:
|
def test_batch_pending(self) -> None:
|
||||||
from service.draft_service import query_pending_projects, run_draft
|
from service.draft_service import query_pending_projects, run_draft
|
||||||
|
|
||||||
@@ -192,5 +255,42 @@ class TestDraftGeneration(unittest.TestCase):
|
|||||||
self.assertEqual(query_pending_projects(project_no="NOPE0001"), [])
|
self.assertEqual(query_pending_projects(project_no="NOPE0001"), [])
|
||||||
|
|
||||||
|
|
||||||
|
class TestCliContract(unittest.TestCase):
|
||||||
|
"""CLI 契约:文档 §8 的六个台账命令可解析,render-html 占位符容错。"""
|
||||||
|
|
||||||
|
def _parser(self):
|
||||||
|
from cli.app import build_parser
|
||||||
|
|
||||||
|
return build_parser()
|
||||||
|
|
||||||
|
def test_contract_commands_parse(self) -> None:
|
||||||
|
parser = self._parser()
|
||||||
|
cases = [
|
||||||
|
["import-projects", "--projects-json", "x.json"],
|
||||||
|
["import-achievements", "--achievements-json", "-"],
|
||||||
|
["import-expenses", "--expenses-json", "x.json"],
|
||||||
|
["list-projects", "--status", "待结题"],
|
||||||
|
["list-achievements", "--project-no", "NSFC2026003"],
|
||||||
|
["list-expenses", "--project-no", "NSFC2026003"],
|
||||||
|
["add-project", "--project-no", "X", "--approved-budget", "500000"],
|
||||||
|
]
|
||||||
|
for argv in cases:
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
self.assertTrue(callable(getattr(args, "handler", None)), argv)
|
||||||
|
|
||||||
|
def test_legacy_import_list_aliases_kept(self) -> None:
|
||||||
|
parser = self._parser()
|
||||||
|
args = parser.parse_args(["import", "--projects-json", "x.json"])
|
||||||
|
self.assertEqual(args.projects_json, "x.json")
|
||||||
|
args = parser.parse_args(["list", "--kind", "expenses", "--project-no", "X"])
|
||||||
|
self.assertEqual(args.kind, "expenses")
|
||||||
|
|
||||||
|
def test_render_html_unresolved_placeholder_falls_back(self) -> None:
|
||||||
|
parser = self._parser()
|
||||||
|
args = parser.parse_args(["render-html", "--report-id", "{{id}}"])
|
||||||
|
# 未解析占位符不应让渲染崩溃:handler 内部按缺省(最近 10 份)处理
|
||||||
|
self.assertEqual(args.report_id, "{{id}}")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user