diff --git a/pipeline_bidding/bid_ability.py b/pipeline_bidding/bid_ability.py index ef1c19a..16fa5a2 100644 --- a/pipeline_bidding/bid_ability.py +++ b/pipeline_bidding/bid_ability.py @@ -15,6 +15,10 @@ from pipeline_core import ( SlashCommand, register_slash_command, ) +from .bid_stage_reentry import ( + bid_stage_reentry, bid_reentry_describe, bid_progress_summary, +) + logger = logging.getLogger("pipeline.bidding.ability") PIPELINE_ID = "bidding_general" @@ -636,6 +640,10 @@ def register_bid_ability(): roles=BID_ROLES, flow_stages=BID_FLOW_STAGES, flow_templates=BID_FLOW_TEMPLATES, + # 流程修订/定向重做钩子(2026-09-17,引擎 revise_flow_plan/confirm 回调) + stage_reentry_describe=bid_reentry_describe, + stage_reentry=bid_stage_reentry, + progress_summary=bid_progress_summary, menus=[ {"label": "📄 招标文件", "icon": "", "url": "/pipeline-bidding/bid_tender_files/index.ui", "type": "popup", "width": "88%", "height": "82%", "require_project": True}, diff --git a/pipeline_bidding/bid_stage_reentry.py b/pipeline_bidding/bid_stage_reentry.py new file mode 100644 index 0000000..b5826c3 --- /dev/null +++ b/pipeline_bidding/bid_stage_reentry.py @@ -0,0 +1,393 @@ +# -*- coding: utf-8 -*- +"""投标产线阶段重做钩子(stage_reentry / describe / progress_summary)。 + +2026-09-17 用户裁定:用户有权调整流程,并要求从流程的任何一步按照意见重做; +或者只给意见,由 LLM 判定注入点(detect_reentry_stage,引擎层通用机制)。 +本模块把「重置投标流程某阶段」的产线语义知识实现为引擎钩子: + +- bid_stage_reentry(sor, project_id, stage_key, affected, user_requirements, operator) + 按注入阶段执行下游重置(清产出/复位状态/作废交付物/取消在办任务),之后对账器 + 按既有逻辑自动从该阶段重新推进。破坏性动作已由用户在流程修订确认待办里授权 + (确认时引擎回调本钩子——template_confirm 同款确认门模式)。 +- bid_reentry_describe:重做影响说明(进确认待办,破坏性预告)。 +- bid_progress_summary:项目进度摘要(LLM 判注入点上下文)。 + +重置语义(affected = 注入阶段+依赖闭包下游,引擎算好传入): + analysis_* 清对应产出表+QC记录(reqs_outline 连带清骨架=毁章节,如实预告) + qc_analysis 只清 QC 记录(产出保留,对账器自动重派复审) + tech_analysis/tech_qc/template_confirm(甘肃场景:补技术方案部分) + 清 bid_tech_items(含summary)+tech QC 记录;technical 章节**置 pending + 不删不清正文**(A3 链激活条件是「存在技术章且全 pending」,删章链 + 永不激活;正文保留到模版确认时被骨架整体替换,镜像/docx 里仍可查); + 作废 pending 模版确认待办+标书+交付待办 → A3 裁剪链重新激活: + 抽取→tech QC→PM 模版裁剪→用户确认骨架→技术章重写;商务/报价章不动 + prep 作废 prep 完成记录(对账器重派资料准备) + chapter_write 全部章节复位 pending+清正文+revise清零 → 编写链重跑 + chapter_review approved/rejected/reviewing 章节复位 written(正文保留)重审 + compose/whole_score/delivery(含 tech_* 对应段) + 标书全版本置 rejected+交付确认待办作废 → 对账器重派合成/评分/重发确认 +统一收尾:affected 阶段角色的在办任务取消(防旧任务残留跑脏数据);凡触碰章节 +或合成的重置,passed 标书一律作废(防僵尸交付指向旧版——2026-09-16 同款教训)。 +""" + +import json +import logging + +from .bid_common import ( + record, + QC_TYPE_TECH, + STAGE_ANALYSIS, STAGE_QUALS, STAGE_REQS_OUTLINE, STAGE_COST_BENEFIT, + STAGE_QC, STAGE_PREP, STAGE_CH_REVIEW, STAGE_WHOLE_SCORE, STAGE_DELIVERY, + STAGE_TECH_ANALYSIS, STAGE_TECH_QC, STAGE_TEMPLATE_CONFIRM, + STAGE_TECH_CH_WRITE, STAGE_TECH_CH_QC, STAGE_TECH_COMPOSE, STAGE_TECH_DELIVERY, +) + +logger = logging.getLogger("pipeline.bidding.reentry") + +PIPELINE_ID = "bidding_general" + +# 分析维度 → (产出表, QC类型);reqs_outline 双表(要求+骨架)单独处理 +_DIM_TABLES = { + "scoring": ("bid_scoring_items", "scoring_items"), + "quals": ("bid_qualifications", "qualifications"), + "cost_benefit": ("bid_cost_benefit", "cost_benefit"), +} +_DIM_STAGE_MAP = { + STAGE_ANALYSIS: "scoring", + STAGE_QUALS: "quals", + STAGE_REQS_OUTLINE: "reqs_outline", + STAGE_COST_BENEFIT: "cost_benefit", +} + +_OPEN_STATES = ("submitted", "running", "review", "qc_review", "waiting") + +# 触发「标书+交付待办作废」的注入阶段集合(触碰章节/合成/评分/交付的任何重置) +_VOID_DOC_STAGES = {"compose", "chapter_write", STAGE_CH_REVIEW, STAGE_WHOLE_SCORE, + STAGE_DELIVERY, STAGE_TECH_ANALYSIS, STAGE_TECH_QC, + STAGE_TEMPLATE_CONFIRM, STAGE_TECH_CH_WRITE, STAGE_TECH_CH_QC, + STAGE_TECH_COMPOSE, STAGE_TECH_DELIVERY, STAGE_REQS_OUTLINE} + + +async def _cancel_open_tasks(sor, project_id, roles, detail): + """取消指定角色的在办任务(重做前置清场;affected 阶段角色集合由调用方算)。""" + if not roles: + return 0 + states = "','".join(_OPEN_STATES) + role_in = "','".join(sorted(roles)) + recs = await sor.sqlExe( + "SELECT id FROM pipeline_tasks WHERE tenant_id=${p}$ AND pipeline_id='role_task' " + "AND role IN ('" + role_in + "') AND state IN ('" + states + "')", + {"p": project_id}) + await sor.sqlExe("COMMIT", {}) + n = 0 + for r in (recs or []): + tid = getattr(r, "id", "") + if not tid: + continue + await sor.sqlExe( + "UPDATE pipeline_tasks SET state='cancelled', claimed_by=NULL, updated_at=NOW() " + "WHERE id=${t}$ AND state IN ('" + states + "')", {"t": tid}) + n += 1 + if n: + await sor.sqlExe("COMMIT", {}) + return n + + +async def _void_docs_and_delivery(sor, project_id, detail): + """标书全版本作废 + pending 交付确认待办作废(防僵尸交付指向旧版)。 + + 对账器语义:最新标书 rejected → E 段全章 approved 后重派合成(v+1); + G/T7 段对 cancelled/rejected 待办视为不存在重发新版确认待办。 + """ + await sor.sqlExe( + "UPDATE bid_documents SET status='rejected', updated_at=NOW() " + "WHERE project_id=${p}$ AND status IN ('draft','reviewing','passed')", + {"p": project_id}) + # 注:pipeline_human_tasks 无 updated_at 列 + await sor.sqlExe( + "UPDATE pipeline_human_tasks SET status='cancelled' " + "WHERE project_id=${p}$ AND task_type='bid_delivery_confirm' AND status='pending'", + {"p": project_id}) + await sor.sqlExe("COMMIT", {}) + await record(sor, project_id, "bid_documents", project_id, "reentry_void", + to_state="rejected", who="system.flow_reentry", detail=detail[:480]) + await sor.sqlExe("COMMIT", {}) + + +async def _delete_orphan_gaps(sor, project_id, chapter_ids=None, all_pending=False): + """清理材料缺口占位记录(章节被删/复位后 pending 缺口成孤儿;filled/waived 留审计)。 + + chapter_ids:删这些章节关联的 pending 缺口;all_pending:删项目全部 pending 缺口。 + """ + try: + if all_pending: + await sor.sqlExe( + "UPDATE bid_material_gaps SET status='waived', " + "fill_note='流程定向重做:章节重建,旧缺口作废', updated_at=NOW() " + "WHERE project_id=${p}$ AND status='pending'", {"p": project_id}) + elif chapter_ids: + for cid in chapter_ids: + await sor.sqlExe( + "UPDATE bid_material_gaps SET status='waived', " + "fill_note='流程定向重做:章节重置,旧缺口作废', updated_at=NOW() " + "WHERE project_id=${p}$ AND chapter_id=${c}$ AND status='pending'", + {"p": project_id, "c": cid}) + await sor.sqlExe("COMMIT", {}) + except Exception as e: # noqa: BLE001 + logger.warning("reentry: 材料缺口清理失败(不阻断): %s", str(e)[:160]) + + +async def _reset_tech_chain(sor, project_id, detail): + """技术标裁剪链重置(tech_analysis/tech_qc/template_confirm 注入共用)。 + + A3 链激活五条件复原:无 summary 行 + technical 章全 pending + outline QC 仍通过 + (不动五类解析 QC 记录)→ 对账器自动重派 tech_analysis → tech_qc → PM 模版任务 + → 用户确认骨架(confirm_tech_template 标准模式届时整体替换 technical 章)。 + ⚠️ 技术章**置 pending 不删不清正文**:删章则 `bool(_tech_chs)` 不成立,链永不 + 激活;正文保留供用户/写者参照,模版确认时才被新骨架替换。 + """ + await sor.sqlExe("DELETE FROM bid_tech_items WHERE project_id=${p}$", {"p": project_id}) + await sor.sqlExe( + "DELETE FROM bid_qc_reviews WHERE project_id=${p}$ AND qc_type=${t}$", + {"p": project_id, "t": QC_TYPE_TECH}) + # 技术章复位 pending(保正文);正文里的占位缺口记录同步作废(重写时会重新登记) + recs = await sor.sqlExe( + "SELECT id FROM bid_chapters WHERE project_id=${p}$ AND section='technical'", + {"p": project_id}) + await sor.sqlExe("COMMIT", {}) + tech_ids = [getattr(r, "id", "") for r in (recs or []) if getattr(r, "id", "")] + await _delete_orphan_gaps(sor, project_id, chapter_ids=tech_ids) + await sor.sqlExe( + "UPDATE bid_chapters SET status='pending', revise_count=0, review_score=NULL, " + "updated_at=NOW() WHERE project_id=${p}$ AND section='technical'", + {"p": project_id}) + await sor.sqlExe( + "UPDATE pipeline_human_tasks SET status='cancelled' " + "WHERE project_id=${p}$ AND task_type='tech_template_confirm' AND status='pending'", + {"p": project_id}) + # 技术章在办编写任务取消(chapter_write 不在 affected 角色集里,写者任务不会被 + # 统一收尾段取消——技术章已复位 pending,旧编写任务继续写会撞上模版确认的 + # 「全 pending 才可替换」守卫,必须清场) + states = "','".join(_OPEN_STATES) + # LIKE 模式走参数绑定(sqlor ${kw}$ → %s):SQL 文本裸写 % 会被 pymysql + # 的 query%args 格式化吃掉(2026-09-16 踩坑同款),必须参数化 + _kw = '%"section": "technical"%' + await sor.sqlExe( + "UPDATE pipeline_tasks SET state='cancelled', claimed_by=NULL, updated_at=NOW() " + "WHERE tenant_id=${p}$ AND pipeline_id='role_task' AND role='agent.bid_writer' " + "AND state IN ('" + states + "') AND params LIKE ${kw}$", + {"p": project_id, "kw": _kw}) + await sor.sqlExe("COMMIT", {}) + await record(sor, project_id, "bid_tech_items", project_id, "reentry_reset", + who="system.flow_reentry", detail=detail[:480]) + await sor.sqlExe("COMMIT", {}) + + +async def bid_stage_reentry(sor, project_id, stage_key, affected_keys, + user_requirements="", operator_id=None): + """执行阶段重置(引擎 confirm_flow_plan 回调)。返回 (ok, msg)。""" + affected = set(affected_keys or []) + if not affected: + affected = {stage_key} + detail = "定向重做注入点=%s(用户意见:%s)" % (stage_key, (user_requirements or "")[:200]) + msgs = [] + + # ── 分析维度重置 ── + for st in (STAGE_ANALYSIS, STAGE_QUALS, STAGE_REQS_OUTLINE, STAGE_COST_BENEFIT): + if st not in affected: + continue + dim = _DIM_STAGE_MAP[st] + if dim == "reqs_outline": + # 骨架重解析 = 章节全毁(新骨架由 create_chapter_outline 重建),如实预告过 + await _delete_orphan_gaps(sor, project_id, all_pending=True) + await sor.sqlExe("DELETE FROM bid_doc_requirements WHERE project_id=${p}$", + {"p": project_id}) + await sor.sqlExe("DELETE FROM bid_chapters WHERE project_id=${p}$", + {"p": project_id}) + await sor.sqlExe( + "DELETE FROM bid_qc_reviews WHERE project_id=${p}$ AND qc_type IN " + "('doc_requirements','chapter_outline')", {"p": project_id}) + msgs.append("已清空投标文件要求+章节骨架与全部已写正文+对应QC记录(章节将重建重写)") + else: + tbl, qc = _DIM_TABLES[dim] + await sor.sqlExe("DELETE FROM %s WHERE project_id=${p}$" % tbl, {"p": project_id}) + await sor.sqlExe( + "DELETE FROM bid_qc_reviews WHERE project_id=${p}$ AND qc_type=${t}$", + {"p": project_id, "t": qc}) + msgs.append("已清空 %s 产出+QC记录(对账器将重派解析)" % tbl) + await sor.sqlExe("COMMIT", {}) + + # ── QC 审核重置(qc_analysis 单独注入:只清 QC 记录,产出保留自动重派复审)── + if STAGE_QC in affected and not any( + s in affected for s in (STAGE_ANALYSIS, STAGE_QUALS, STAGE_REQS_OUTLINE, + STAGE_COST_BENEFIT)): + from .bid_common import QC_TYPES + qtypes = "','".join(QC_TYPES) + await sor.sqlExe( + "DELETE FROM bid_qc_reviews WHERE project_id=${p}$ AND qc_type IN ('" + qtypes + "')", + {"p": project_id}) + await sor.sqlExe("COMMIT", {}) + msgs.append("已清空解析 QC 记录(各维度产出保留,对账器自动重派复审)") + + # ── 技术标裁剪链重置 ── + if affected & {STAGE_TECH_ANALYSIS, STAGE_TECH_QC, STAGE_TEMPLATE_CONFIRM}: + await _reset_tech_chain(sor, project_id, detail) + msgs.append("已清空技术评估项+tech QC记录,技术章复位 pending(正文保留至模版确认替换)," + "A3 裁剪链将重新激活:抽取→QC→PM模版裁剪→用户确认骨架→技术章重写") + + # ── 资料准备重置 ── + if STAGE_PREP in affected: + await sor.sqlExe( + "UPDATE pipeline_tasks SET state='cancelled', claimed_by=NULL, updated_at=NOW() " + "WHERE tenant_id=${p}$ AND pipeline_id='role_task' AND role='agent.bid_prep' " + "AND state IN ('approved','completed')", {"p": project_id}) + await sor.sqlExe("COMMIT", {}) + msgs.append("已作废资料准备完成记录(对账器将重派)") + + # ── 章节编写重置(清正文全部重写)── + if "chapter_write" in affected or STAGE_TECH_CH_WRITE in affected: + await _delete_orphan_gaps(sor, project_id, all_pending=True) + await sor.sqlExe( + "UPDATE bid_chapters SET status='pending', revise_count=0, content='', " + "review_score=NULL, review_comment=NULL, updated_at=NOW() WHERE project_id=${p}$", + {"p": project_id}) + await sor.sqlExe("COMMIT", {}) + msgs.append("已复位全部章节到 pending 并清空正文(编写链重跑)") + # ── 章节评审重置(正文保留,复位 written 重审;chapter_write 在 affected 时跳过 + # ——编写注入已把章节打到 pending,评审自然重走)── + elif STAGE_CH_REVIEW in affected or STAGE_TECH_CH_QC in affected: + await sor.sqlExe( + "UPDATE bid_chapters SET status='written', revise_count=0, review_score=NULL, " + "review_comment=NULL, updated_at=NOW() " + "WHERE project_id=${p}$ AND status IN ('approved','reviewing','rejected')", + {"p": project_id}) + await sor.sqlExe("COMMIT", {}) + msgs.append("已复位章节到 written(正文保留,评审重跑)") + + # ── 标书/交付作废(凡触碰章节/合成/评分/交付的重置统一执行,防僵尸交付)── + if affected & _VOID_DOC_STAGES: + await _void_docs_and_delivery(sor, project_id, detail) + msgs.append("已作废标书(rejected)+交付确认待办(重走合成→评分→交付确认)") + + # ── 统一收尾:affected 阶段角色的在办任务取消(角色集从阶段声明运行时取)── + roles = set() + try: + from pipeline_core import get_flow_stages + for fk in ("", "bid_standard", "bid_tech_proposal"): + for s in (get_flow_stages(PIPELINE_ID, flow_key=fk) or []): + if s.key in affected: + roles.update(s.roles or []) + except Exception as e: # noqa: BLE001 + logger.warning("reentry: 取阶段角色失败(跳过任务清场): %s", str(e)[:120]) + n_cancel = await _cancel_open_tasks(sor, project_id, roles, detail) + if n_cancel: + msgs.append("已取消 %d 个在办角色任务" % n_cancel) + + await record(sor, project_id, "sd_projects", project_id, "stage_reentry", + to_state=stage_key, who=operator_id or "user", + detail=(detail + ";动作:" + ";".join(msgs))[:900]) + await sor.sqlExe("COMMIT", {}) + logger.info("bid stage_reentry done: project=%s stage=%s affected=%s", + project_id, stage_key, sorted(affected)) + return True, ";".join(msgs) if msgs else "无需重置动作(该阶段无既有产出)" + + +# ══════════════════════ 影响说明(进确认待办,破坏性预告) ══════════════════════ + +_DESC = { + STAGE_ANALYSIS: "清空评分项与得分规则(bid_scoring_items)+对应QC记录,重新解析并复审", + STAGE_QUALS: "清空资质清单(bid_qualifications)+对应QC记录,重新解析并复审", + STAGE_REQS_OUTLINE: "清空投标文件要求(bid_doc_requirements)+**全部章节骨架与已写正文**(bid_chapters)+对应QC记录——章节将全部重建重写,**已有正文不保留**", + STAGE_COST_BENEFIT: "清空成本收益分析(bid_cost_benefit)+对应QC记录,重新解析并复审", + STAGE_QC: "清空全部解析QC记录(bid_qc_reviews),各维度产出保留并自动重派复审", + STAGE_TECH_ANALYSIS: "清空技术评估项(bid_tech_items)+tech QC记录;技术章复位待重写(现有正文保留至模版确认时替换);技术标模版裁剪链重新激活(抽取→QC→PM裁剪模版→**你重新确认骨架**→技术章重写→重合成→重评分);商务/报价章不受影响", + STAGE_TECH_QC: "清空tech QC记录+技术评估项重审,技术章复位;模版裁剪链从QC段重走;商务/报价章不受影响", + STAGE_TEMPLATE_CONFIRM: "撤销既有/待确认的技术章骨架+技术评估项+tech QC记录(裁剪链全链重走:抽取→QC→PM重新检索模版裁剪→**你重新确认骨架**);商务/报价章不受影响", + STAGE_PREP: "作废资料准备完成记录,重新执行知识库资质匹配", + "chapter_write": "**全部章节复位重写(已写正文清空)**→重审→标书作废重合成→重评分→重发交付确认", + STAGE_CH_REVIEW: "已通过/被退回章节复位重审(正文保留);不达标自动退回重写→重合成→重评分", + "compose": "标书全版本作废(rejected)+交付确认待办作废;全部章节通过后自动重合成新版→重评分→重发交付确认", + STAGE_WHOLE_SCORE: "最新标书作废+交付待办作废,自动重派整书评分;不达标按意见打回章节", + STAGE_DELIVERY: "交付确认待办作废+标书置 rejected,重评分后重发交付确认(仅重走确认环节;要改内容请注入更早阶段或用交付待办的退回通道)", + STAGE_TECH_CH_WRITE: "技术方案书全部章节复位重写(已写正文清空)→重QC→重合成→重发交付确认", + STAGE_TECH_CH_QC: "技术方案书章节复位重审(正文保留,对标需求书覆盖度)", + STAGE_TECH_COMPOSE: "技术方案书作废+交付待办作废,重合成→重发交付确认", + STAGE_TECH_DELIVERY: "技术方案书交付确认待办作废+文档置 rejected,重发交付确认", +} + + +async def bid_reentry_describe(sor, project_id, stage_key, affected_keys): + """重做影响说明(确认待办正文渲染,破坏性预告)。""" + lines = [] + for k in (affected_keys or []): + d = _DESC.get(k) + if d: + lines.append("- 【%s】%s" % (k, d)) + if not lines: + lines.append("- 阶段 %s:按产线流转机制自动重走(无既有产出需清理)" % stage_key) + lines.append("- 通用动作:上述阶段角色的在办任务全部取消;标书/交付确认待办作废" + "(防交付物指向旧版);流程从注入阶段自动重新推进,无需人工再派任务。") + return "\n".join(lines) + + +async def bid_progress_summary(sor, project_id): + """项目进度摘要(detect_reentry_stage 的 LLM 上下文)。""" + lines = [] + try: + recs = await sor.sqlExe( + "SELECT status, COUNT(*) AS c FROM bid_chapters WHERE project_id=${p}$ " + "GROUP BY status", {"p": project_id}) + await sor.sqlExe("COMMIT", {}) + ch = {str(getattr(r, "status", "?")): getattr(r, "c", 0) for r in (recs or [])} + if ch: + lines.append("章节状态分布:%s" % json.dumps(ch, ensure_ascii=False)) + recs = await sor.sqlExe( + "SELECT section, COUNT(*) AS c FROM bid_chapters WHERE project_id=${p}$ " + "GROUP BY section", {"p": project_id}) + await sor.sqlExe("COMMIT", {}) + sec = {str(getattr(r, "section", "?")): getattr(r, "c", 0) for r in (recs or [])} + if sec: + lines.append("章节分区分布(business商务/technical技术/price报价):%s" + % json.dumps(sec, ensure_ascii=False)) + recs = await sor.sqlExe( + "SELECT version, status, chapter_count FROM bid_documents WHERE project_id=${p}$ " + "ORDER BY version DESC LIMIT 3", {"p": project_id}) + await sor.sqlExe("COMMIT", {}) + for r in (recs or []): + lines.append("标书 v%s:%s(%s章)" % (getattr(r, "version", "?"), + getattr(r, "status", "?"), + getattr(r, "chapter_count", "?"))) + recs = await sor.sqlExe( + "SELECT COUNT(*) AS c FROM bid_tech_items WHERE project_id=${p}$", {"p": project_id}) + await sor.sqlExe("COMMIT", {}) + n_ti = getattr(recs[0], "c", 0) if recs else 0 + recs = await sor.sqlExe( + "SELECT COUNT(*) AS c FROM bid_tech_items WHERE project_id=${p}$ AND kind='summary'", + {"p": project_id}) + await sor.sqlExe("COMMIT", {}) + n_sum = getattr(recs[0], "c", 0) if recs else 0 + lines.append("技术评估项:%s 行(模版裁剪摘要 %s 行%s)" % ( + n_ti, n_sum, "——技术标模版裁剪链已走完" if n_sum else "——裁剪链未完成或未启用")) + recs = await sor.sqlExe( + "SELECT qc_type, round, fit_score, passed FROM bid_qc_reviews WHERE project_id=${p}$ " + "ORDER BY created_at DESC LIMIT 8", {"p": project_id}) + await sor.sqlExe("COMMIT", {}) + for r in (recs or []): + lines.append("QC %s r%s:%s分 passed=%s" % ( + getattr(r, "qc_type", "?"), getattr(r, "round", "?"), + getattr(r, "fit_score", "?"), getattr(r, "passed", "?"))) + recs = await sor.sqlExe( + "SELECT task_type, title, status FROM pipeline_human_tasks WHERE project_id=${p}$ " + "AND status='pending' ORDER BY created_at DESC LIMIT 8", {"p": project_id}) + await sor.sqlExe("COMMIT", {}) + for r in (recs or []): + lines.append("在办待办 [%s] %s" % (getattr(r, "task_type", "?"), + str(getattr(r, "title", ""))[:60])) + for tbl in ("bid_scoring_items", "bid_qualifications", "bid_doc_requirements"): + recs = await sor.sqlExe( + "SELECT COUNT(*) AS c FROM %s WHERE project_id=${p}$" % tbl, {"p": project_id}) + await sor.sqlExe("COMMIT", {}) + lines.append("%s:%s 行" % (tbl, getattr(recs[0], "c", 0) if recs else 0)) + except Exception as e: # noqa: BLE001 + logger.warning("bid_progress_summary failed: %s", str(e)[:160]) + return "\n".join(lines)