fix: 预览按 draft-{id} 命名且重建全量同构 + C1 空台账改判数据待补 + 立项经费结余结论 + CLI 拆分 import-*/list-* + docx 占位黄底
This commit is contained in:
@@ -32,13 +32,15 @@ def write_docx(project: dict[str, Any], sections: dict[str, list[str]], batch_no
|
||||
"""生成《结题报告(初稿)》docx,返回文件路径。"""
|
||||
_check_docx_available()
|
||||
import docx
|
||||
from docx.enum.text import WD_COLOR_INDEX
|
||||
from docx.shared import Pt
|
||||
|
||||
log = _log()
|
||||
log.info("docx_render_start project_no=%s stage=docx", project.get("project_no"))
|
||||
|
||||
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.alignment = 1
|
||||
|
||||
@@ -68,8 +70,8 @@ def write_docx(project: dict[str, Any], sections: dict[str, list[str]], batch_no
|
||||
p = document.add_paragraph()
|
||||
run = p.add_run(text)
|
||||
if text.startswith("[待补充"):
|
||||
run.font.color.rgb = None # 占位文本以提示语区分,不依赖颜色
|
||||
run.italic = True
|
||||
run.font.highlight_color = WD_COLOR_INDEX.YELLOW # 占位黄底提示,便于教师定位待写章节
|
||||
|
||||
notice = document.add_paragraph()
|
||||
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"
|
||||
path = get_output_dir() + "/" + filename
|
||||
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
|
||||
|
||||
@@ -60,6 +60,15 @@ def _camel(name: str) -> str:
|
||||
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:
|
||||
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", "项目类型", "资助渠道"),
|
||||
"startDate": ("start_date", "执行期开始", "开始日期"),
|
||||
"endDate": ("end_date", "执行期结束", "结束日期"),
|
||||
"approvedBudget": ("approved_budget", "立项经费", "批准经费"),
|
||||
"expected": ("expected_json", "预期成果指标", "预期指标"),
|
||||
"annualReportExcerpt": ("annual_report_excerpt", "年度报告摘句", "年度报告"),
|
||||
"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]]:
|
||||
"""C1:按类型对比预期指标与实际成果。"""
|
||||
"""C1:按类型对比预期指标与实际成果。
|
||||
|
||||
成果台账整表为空时无从对比,一律判"数据待补"(不下"未达标"结论);
|
||||
台账非空但某类型无记录按实际 0 计(事实陈述)。
|
||||
"""
|
||||
counts: dict[str, int] = {}
|
||||
for ach in achievements:
|
||||
ach_type = str(ach.get("achievement_type") or "").strip()
|
||||
counts[ach_type] = counts.get(ach_type, 0) + 1
|
||||
ledger_empty = not achievements
|
||||
rows = []
|
||||
for item in expected:
|
||||
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)
|
||||
if target_v is None:
|
||||
verdict, detail = "数据待补", f"预期指标未配置数值(类型:{item_type})"
|
||||
elif ledger_empty:
|
||||
verdict, detail = "数据待补", f"预期 {target_v} {unit},成果清单尚未录入,无法对比(W1)"
|
||||
elif actual is None:
|
||||
verdict, detail = "未达标", f"预期 {target_v} {unit},台账中未录入该类成果"
|
||||
verdict, detail = "未达标", f"预期 {target_v} {unit},实际 0 {unit}(台账中未录入该类成果)"
|
||||
actual = 0
|
||||
else:
|
||||
if actual > target_v:
|
||||
verdict = "超额完成"
|
||||
@@ -177,7 +195,11 @@ def _c3_expense_sentences(expenses: list[dict[str, Any]], total_approved: Option
|
||||
return [], "经费决算明细尚未录入,请在补录后重新生成初稿(W1)。"
|
||||
tail = f"。项目决算合计支出 {_wan(total)}。"
|
||||
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
|
||||
|
||||
|
||||
@@ -224,7 +246,8 @@ def build_draft_sections(
|
||||
expenses = [{_snake(k): v for k, v in _norm_expense(e).items()} for e in expenses]
|
||||
|
||||
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 ""
|
||||
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 = {
|
||||
"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()
|
||||
try:
|
||||
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
|
||||
(batch_no, project_no, project_name, completion_json, narrative_points_json, draft_file, preview_file)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, '')
|
||||
""",
|
||||
(
|
||||
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["narrative_points"], ensure_ascii=False),
|
||||
draft_file,
|
||||
preview_file,
|
||||
),
|
||||
)
|
||||
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(
|
||||
"UPDATE closing_projects SET status = '初稿已生成', updated_at = ? WHERE 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()
|
||||
if values.get("status") and values["status"] not in _KNOWN_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, ""):
|
||||
raw = fields["expected_json"]
|
||||
try:
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
"""初稿 HTML 预览组装。
|
||||
|
||||
与 Word 初稿同构内容写入 `{skill_data}/html/draft-{id}.html`,宿主侧栏可预览;
|
||||
重建走固定 Action id `render-html`。
|
||||
重建走固定 Action id `render-html`(按台账全量重建,与生成时同一内核)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
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"
|
||||
|
||||
|
||||
def _log():
|
||||
try:
|
||||
return get_skill_logger()
|
||||
except RuntimeError:
|
||||
return None
|
||||
|
||||
|
||||
def _esc(value: Any) -> str:
|
||||
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)
|
||||
log = get_skill_logger()
|
||||
path = write_html_page("draft-preview.html", html_text)
|
||||
log.info("draft_preview_written project_no=%s path=%s stage=report", draft.get("project_no"), path)
|
||||
log = _log()
|
||||
path = write_html_page(f"draft-{draft_id}.html", html_text)
|
||||
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
|
||||
|
||||
|
||||
def build_preview_for_draft_id(draft_id: int) -> str:
|
||||
"""按初稿记录 id 重建 HTML 预览(重建入口用)。"""
|
||||
"""按初稿记录 id 从台账全量重建 HTML 预览(与 Word 初稿同构,含决算/成果/占位章节)。"""
|
||||
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.info("preview_render_start draft_id=%s stage=render", draft_id)
|
||||
log = _log()
|
||||
if log:
|
||||
log.info("preview_render_start draft_id=%s stage=render", draft_id)
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
@@ -78,27 +89,25 @@ def build_preview_for_draft_id(draft_id: int) -> str:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
try:
|
||||
completion = json.loads(record.get("completion_json") or "[]")
|
||||
except (TypeError, ValueError):
|
||||
completion = []
|
||||
try:
|
||||
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 目录可取)"],
|
||||
project_no = str(record.get("project_no") or "")
|
||||
projects = query_pending_projects(project_no=project_no)
|
||||
project = projects[0] if projects else {
|
||||
"project_no": project_no,
|
||||
"project_name": str(record.get("project_name") or ""),
|
||||
}
|
||||
sections_data = build_draft_sections(
|
||||
project,
|
||||
list_achievements(project_no),
|
||||
list_expenses(project_no),
|
||||
)
|
||||
draft = {
|
||||
"project_no": record.get("project_no"),
|
||||
"project_name": record.get("project_name"),
|
||||
"batch_no": record.get("batch_no"),
|
||||
"sections": sections,
|
||||
"project_no": project_no,
|
||||
"project_name": str(project.get("project_name") or record.get("project_name") or ""),
|
||||
"batch_no": str(record.get("batch_no") or ""),
|
||||
"sections": sections_data["sections"],
|
||||
}
|
||||
html_text = build_preview_html(draft)
|
||||
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
|
||||
|
||||
@@ -260,6 +260,7 @@ def cmd_add_project(
|
||||
program: str = "",
|
||||
start: str = "",
|
||||
end: str = "",
|
||||
approved_budget: str = "",
|
||||
expected_json: str = "",
|
||||
excerpt: str = "",
|
||||
status: str = "",
|
||||
@@ -278,7 +279,7 @@ def cmd_add_project(
|
||||
print(f"ERROR:ENTITLEMENT_DENIED {reason}")
|
||||
return 1
|
||||
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:
|
||||
fields[key] = value
|
||||
payload = upsert_project(fields)
|
||||
@@ -411,6 +412,86 @@ def cmd_import(projects_json: str = "", achievements_json: str = "", expenses_js
|
||||
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:
|
||||
log = _get_task_logger()
|
||||
from service.ledger_service import list_achievements, list_expenses, list_projects
|
||||
|
||||
Reference in New Issue
Block a user