From 051eae7a2553ddd6e130dd25e602aef708bae6c5 Mon Sep 17 00:00:00 2001 From: yumoqing Date: Thu, 17 Sep 2026 22:25:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(delivery):=20=E4=BA=A4=E4=BB=98=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=E5=BE=85=E5=8A=9E=E8=A1=A5=E9=80=80=E5=9B=9E=E9=80=9A?= =?UTF-8?q?=E9=81=93reject=5Fdelivery(2026-09-17=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E6=8A=A5=E9=9A=9C:=E5=BE=85=E5=8A=9E=E5=8F=AA=E6=9C=89?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=E6=97=A0=E9=80=80=E5=9B=9E)=E2=80=94?= =?UTF-8?q?=E2=80=94=E7=94=A8=E6=88=B7=E4=B8=93=E5=B1=9E=E7=AB=AF=E7=82=B9?= =?UTF-8?q?bid=5Fdelivery=5Freject.dspy(agent=E6=97=A0=E6=AD=A4=E5=B7=A5?= =?UTF-8?q?=E5=85=B7,=E9=80=80=E5=9B=9E=E6=98=AF=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E8=A3=81=E5=86=B3)+=E6=9D=83=E9=99=90=E5=A4=8D=E7=94=A8=5Fchec?= =?UTF-8?q?k=5Fconfirm=5Foperator;=E6=9C=BA=E5=88=B6:=E5=BE=85=E5=8A=9Erej?= =?UTF-8?q?ected+=E6=84=8F=E8=A7=81=E5=AD=98result=5Fdata(tech=5Ftemplate?= =?UTF-8?q?=E9=A9=B3=E5=9B=9E=E5=90=8C=E6=AC=BE=E7=BA=A6=E5=AE=9A)?= =?UTF-8?q?=E2=86=92passed=E6=A0=87=E4=B9=A6=E7=BD=AErejected=E2=86=92?= =?UTF-8?q?=E6=B4=BEscorer=20bid=5Fdelivery=5Freject=E4=BB=BB=E5=8A=A1(?= =?UTF-8?q?=E5=B8=A6reject=5Fcomment)=E6=8C=89=E6=84=8F=E8=A7=81request=5F?= =?UTF-8?q?chapter=5Frevise=E6=89=93=E5=9B=9E=E7=AB=A0=E8=8A=82=E2=86=92?= =?UTF-8?q?=E6=97=A2=E6=9C=89=E9=93=BE=E8=B7=AF=E8=87=AA=E5=8A=A8=E9=87=8D?= =?UTF-8?q?=E5=86=99=E9=87=8D=E5=AE=A1=E9=87=8D=E5=90=88=E6=88=90=E9=87=8D?= =?UTF-8?q?=E8=AF=84=E5=88=86=E9=87=8D=E5=8F=91=E4=BA=A4=E4=BB=98=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4;approved=E7=AB=A0=E8=8A=82revise=5Fcount=E5=A4=8D?= =?UTF-8?q?=E4=BD=8D(=E7=94=A8=E6=88=B7=E7=BA=A7=E9=80=80=E5=9B=9E=3D?= =?UTF-8?q?=E6=96=B0=E4=BF=AE=E8=AE=A2=E9=A2=84=E7=AE=97,=E4=B8=8D?= =?UTF-8?q?=E5=A4=8D=E4=BD=8D=E5=88=99=E7=83=A7=E6=BB=A1max=5Frevise?= =?UTF-8?q?=E7=9A=84=E7=AB=A0=E8=A2=ABC=E6=AE=B5=E6=8B=A6=E6=AD=BB);E/T6?= =?UTF-8?q?=E6=AE=B5=E9=85=8D=E5=A5=97=E9=97=A8=E7=A6=81:=E9=A9=B3?= =?UTF-8?q?=E5=9B=9E=E4=BB=BB=E5=8A=A1=E5=9C=A8=E5=8A=9E=E6=9C=9F=E9=97=B4?= =?UTF-8?q?=E4=B8=8D=E6=B4=BE=E5=90=88=E6=88=90(=E9=98=B2=E7=AB=A0?= =?UTF-8?q?=E8=8A=82=E6=9C=AA=E6=89=93=E5=9B=9E=E5=B0=B1=E6=8A=A2=E5=85=88?= =?UTF-8?q?=E9=87=8D=E5=90=88=E6=88=90=E5=90=8C=E5=86=85=E5=AE=B9=E6=96=B0?= =?UTF-8?q?=E7=89=88=E7=BB=95=E8=BF=87=E7=94=A8=E6=88=B7=E6=84=8F=E8=A7=81?= =?UTF-8?q?);T7=E4=BA=A4=E4=BB=98=E5=BE=85=E5=8A=9E=E8=A1=A5cancelled/reje?= =?UTF-8?q?cted=E8=B1=81=E5=85=8D=E9=87=8D=E5=8F=91=E3=80=82=E9=A1=BA?= =?UTF-8?q?=E5=B8=A6=E4=BF=AEreject=5Fchapter=E4=B8=A4=E5=A4=84:=E2=91=A0?= =?UTF-8?q?=5Fsync=5Fmirror=E6=AD=BB=E4=BB=A3=E7=A0=81(return=E5=90=8E?= =?UTF-8?q?=E6=B0=B8=E4=B8=8D=E6=89=A7=E8=A1=8C,=E9=80=80=E5=9B=9E?= =?UTF-8?q?=E7=AB=A0=E8=8A=82=E9=95=9C=E5=83=8F=E4=B8=8D=E5=90=8C=E6=AD=A5?= =?UTF-8?q?)=E2=91=A1=E8=A1=A5=E6=A0=87=E4=B9=A6=E8=81=94=E5=8A=A8?= =?UTF-8?q?=E4=BD=9C=E5=BA=9F(=E4=B8=8Ereview=5Fchapter=E9=80=80=E5=9B=9E?= =?UTF-8?q?=E5=88=86=E6=94=AF=E5=90=8C=E6=AC=BE=E7=BC=BA=E5=8F=A3:?= =?UTF-8?q?=E6=A0=87=E4=B9=A6passed=E6=97=B6=E4=BA=BA=E5=B7=A5/scorer?= =?UTF-8?q?=E9=80=80=E5=9B=9E=E7=AB=A0=E8=8A=82,=E9=87=8D=E5=86=99?= =?UTF-8?q?=E5=86=85=E5=AE=B9=E6=B0=B8=E8=BF=9C=E8=BF=9B=E4=B8=8D=E4=BA=86?= =?UTF-8?q?=E4=BA=A4=E4=BB=98=E7=89=A9+=E6=97=A7=E4=BA=A4=E4=BB=98?= =?UTF-8?q?=E5=BE=85=E5=8A=9E=E5=83=B5=E5=B0=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 ++ pipeline_bidding/bid_flow.py | 16 ++- pipeline_bidding/bid_review_capability.py | 22 ++++- pipeline_bidding/bid_score_capability.py | 114 ++++++++++++++++++++++ scripts/load_path.py | 1 + wwwroot/api/bid_delivery_reject.dspy | 40 ++++++++ 6 files changed, 199 insertions(+), 2 deletions(-) create mode 100644 wwwroot/api/bid_delivery_reject.dspy diff --git a/README.md b/README.md index 31238ea..38170e5 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,14 @@ bid_chapter_status / bid_review_scope / bid_kb_doc_type / bid_doc_status)。 确认后骨架落 bid_chapters(section=technical) 开写——**标准流程(项目存在非 technical 章) 只替换技术章并落 kind='summary' 裁剪摘要行,商务/报价章原样保留;纯技术方案流程整表清落**; 驳回意见由对账器注入重派模版任务。 +- **api/bid_delivery_reject.dspy** — 交付退回端点(2026-09-17,用户报障「交付确认待办只有 + 确认没有退回」后补)。入参 human_task_id/comment(退回意见必填)。用户专属:权限校验复用 + `_check_confirm_operator`;agent 无此工具(退回是用户裁决)。机制 `reject_delivery`: + 待办置 rejected+意见存 result_data → 最新 passed 标书置 rejected → 派 scorer + `bid_delivery_reject` 任务(params.reject_comment)→ scorer 按意见 request_chapter_revise + 打回问题章节 → 既有链路自动重写→重审→重合成→重评分→重发交付确认;approved 章节 + revise_count 复位(用户级退回=新一轮修订预算)。对账器 E/T6 段配套门禁:驳回处理任务 + 在办期间不派合成(防章节还没被打回就抢先重合成同内容新版,静默绕过用户意见)。 - **bid_task_tree_popup.ui** — ResourceBrowser 复合 widget 弹窗(左树右详情联动)。 - **agent/index.ui** — 驾驶舱入口页(顶栏含任务树按钮)。 - **12 个 CRUD 目录**(bid_tenders / bid_tender_files / bid_scoring_items / diff --git a/pipeline_bidding/bid_flow.py b/pipeline_bidding/bid_flow.py index 491bbc4..a2c4f77 100644 --- a/pipeline_bidding/bid_flow.py +++ b/pipeline_bidding/bid_flow.py @@ -1403,6 +1403,13 @@ async def reconcile_project(sor, project_id, project_name=""): # ── E. 合成标书(全部章节 approved;无标书或最新标书已被打回)── all_approved = all(c.get("status") == CH_APPROVED for c in chapters) if all_approved: + # 交付退回门禁(2026-09-17):bid_delivery_reject 任务在办期间不派合成—— + # 用户退回后标书已置 rejected,若无此门禁,scorer 还没来得及把问题章节 + # 打回(全部章节仍 approved)时 E 段立刻重合成同内容新版 → 用户退回意见 + # 被静默绕过。scorer request_chapter_revise 后章节变 rejected,本门禁自然 + # 让位于 C 段重写派发;任务终态后合成恢复。 + if await _open_tech_tasks(sor, project_id, "bid_delivery_reject") > 0: + return acts + ["waiting: 交付驳回处理任务在办(评分员按用户意见打回章节中)"] if not doc or doc.get("status") == DOC_REJECTED: if await count_open_tasks(sor, project_id, role=R_COMPOSITOR) > 0: return acts + ["waiting: 标书合成任务在办"] @@ -1736,6 +1743,10 @@ async def _reconcile_tech_proposal(sor, project_id, pname, stage_table): if not doc or doc.get("status") == DOC_REJECTED: if not _on(STAGE_TECH_COMPOSE): return acts + ["blocked: 合成阶段被裁但流程要求产出技术方案书,无法完成"] + # 交付退回门禁(2026-09-17,与标准流程 E 段同款):驳回处理任务在办 + # 期间不派合成,防章节还没被打回就抢先重合成同内容新版绕过用户意见。 + if await _open_tech_tasks(sor, project_id, "bid_delivery_reject") > 0: + return acts + ["waiting: 交付驳回处理任务在办(评分员按用户意见打回章节中)"] if await count_open_tasks(sor, project_id, role=R_COMPOSITOR) > 0: return acts + ["waiting: 技术方案书合成任务在办"] tid = await create_role_task( @@ -1758,7 +1769,10 @@ async def _reconcile_tech_proposal(sor, project_id, pname, stage_table): await sor.sqlExe("COMMIT", {}) return acts + ["trimmed: tech_delivery_confirm 已裁剪,技术方案书直接置 delivered,项目完成"] ht = await find_human_task(sor, project_id, HT_DELIVERY_CONFIRM) - if not ht: + # cancelled/rejected 的旧待办视为不存在(与标准流程 G 段同款豁免): + # rejected = 用户退回交付(reject_delivery),打回重写重合成后必须 + # 重发新版确认待办;cancelled = 章节退回联动作废的旧版待办。 + if not ht or ht.get("status") in ("cancelled", "rejected"): hid = await create_human_task( sor, project_id, HT_DELIVERY_CONFIRM, "技术方案书已完成,请确认交付", diff --git a/pipeline_bidding/bid_review_capability.py b/pipeline_bidding/bid_review_capability.py index 64952bf..2c596e7 100644 --- a/pipeline_bidding/bid_review_capability.py +++ b/pipeline_bidding/bid_review_capability.py @@ -258,8 +258,28 @@ async def reject_chapter(chapter_id, comment="", who=None, agent_id=None): await record(sor, c.get("project_id"), TABLE, chapter_id, "reject", from_state=c.get("status"), to_state=CH_REJECTED, who=who, agent_id=agent_id, detail=comment[:480]) - return True, "章节「%s」已退回重写(第 %d 次)" % (c.get("title"), revise) + # ── 标书状态联动作废(2026-09-17 补齐,与 review_chapter 退回分支同款)── + # 本工具被人工/scorer(request_chapter_revise)在标书已合成后调用时,若标书 + # 停在 passed,对账器 E 段只对「无文档 or rejected」派合成 → 重写后的新正文 + # 永远进不了交付物,且 G 段照常走交付确认(2026-09-16 甘肃 v1 同款死锁)。 + try: + await sor.sqlExe( + "UPDATE bid_documents SET status='rejected', updated_at=NOW() " + "WHERE project_id=${pid}$ AND status IN ('draft','reviewing','passed')", + {"pid": c.get("project_id")}) + # 指向旧版的 pending 交付确认待办同步作废(重合成后 G 段自动重发新版) + # 注:pipeline_human_tasks 无 updated_at 列(2026-09-16 实测 schema) + await sor.sqlExe( + "UPDATE pipeline_human_tasks SET status='cancelled' " + "WHERE project_id=${pid}$ AND task_type='bid_delivery_confirm' " + "AND status='pending'", {"pid": c.get("project_id")}) + await sor.sqlExe("COMMIT", {}) + except Exception as e: # noqa: BLE001 + logger.warning("章节退回联动标书作废失败 pid=%s: %s", c.get("project_id"), e) + # 2026-09-17 修死代码:_sync_mirror 原写在 return 之后永不执行——退回章节 + # 镜像文件头停在旧状态,用户看文件误判(与 2026-09-16 评审通过不同步同款)。 await _sync_mirror(sor, c.get("project_id"), chapter_id) + return True, "章节「%s」已退回重写(第 %d 次)" % (c.get("title"), revise) async def list_reviews(project_id, scope="", chapter_id="", limit=50): diff --git a/pipeline_bidding/bid_score_capability.py b/pipeline_bidding/bid_score_capability.py index 5b7fee2..f143b7d 100644 --- a/pipeline_bidding/bid_score_capability.py +++ b/pipeline_bidding/bid_score_capability.py @@ -13,6 +13,7 @@ import logging from .bid_common import ( get_db, new_id, rec_to_dict, rows_to_dicts, to_float, to_int, get_thresholds, record, create_human_task, find_human_task, resolve_project_id, + create_role_task, CH_APPROVED, CH_REJECTED, DOC_DRAFT, DOC_REVIEWING, DOC_PASSED, DOC_REJECTED, HT_DELIVERY_CONFIRM, ) @@ -338,3 +339,116 @@ async def bid_score_report(project_id, limit=50): "comments": (r.get("comments") or "")[:3000], "items": rows_to_dicts(srecs, limit=to_int(limit, 50)), }, ensure_ascii=False, default=str) + + +R_SCORER = "agent.bid_scorer" + + +async def reject_delivery(human_task_id, comment="", operator_id=None): + """用户退回交付(专用端点调用,agent 无此工具——退回是用户裁决)。 + + 2026-09-17 用户报障:交付确认待办只有「确认交付」单按钮,没有退回通道—— + 复核发现问题(内容/用印/密封/口径)时无法把标书打回,只能确认或干等。 + 对齐 tech_template_confirm 的 confirm/reject 双通道模式。 + + 机制(复用既有两级回退循环,不另建链路): + ① 待办置 rejected,退回意见存 result_data(tech_template 驳回同款约定); + ② 最新 passed 标书置 rejected(否则 G 段按 passed 立刻重发确认待办); + ③ 派 scorer「交付驳回处理」任务(task_kind=bid_delivery_reject,params 带 + reject_comment)——scorer 按用户意见 request_chapter_revise 退回问题章节, + 之后 C→D→E→F→G 既有链路自动:重写→重审→重合成→重评分→重发交付确认; + ④ 全部 approved 章节 revise_count 复位(用户级退回=新一轮修订预算;不复位则 + 上轮烧满 max_revise 的章节被 C 段门禁拦死,退回即死锁——对齐 C1 段人工 + 完成待办后复位 revise_count 的既有语义); + ⑤ E 段配套门禁(bid_flow):bid_delivery_reject 任务在办期间不派合成—— + 否则章节还没被退回就抢先重合成同内容新版,用户意见被静默绕过。 + """ + if not human_task_id: + return False, "缺少 human_task_id" + if not operator_id: + return False, "未登录" + if not (comment or "").strip(): + return False, "退回必须填写意见(说明标书哪里需要修改)" + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + recs = await sor.sqlExe( + "SELECT * FROM pipeline_human_tasks WHERE id=${h}$", {"h": human_task_id}) + await sor.sqlExe("COMMIT", {}) + if not recs: + return False, "待办不存在: %s" % human_task_id + ht = rec_to_dict(recs[0]) + if ht.get("task_type") != HT_DELIVERY_CONFIRM: + return False, "该待办不是交付确认类型(%s)" % ht.get("task_type") + if ht.get("status") != "pending": + return False, "待办已处理(当前 %s)" % ht.get("status") + project_id = ht.get("project_id") or "" + if not project_id: + return False, "待办未挂项目" + # 权限:复用引擎 _check_confirm_operator(同机构 + owner.superuser/项目创建者) + # ——与 flow_plan/tech_template 确认门语义一致,不自写校验(2026-09-15 实测 + # 自写「仅同机构」会误拒超管)。 + try: + from pipeline_service.flow_plan_capability import _check_confirm_operator + ok_op, err_op = await _check_confirm_operator( + sor, {"project_id": project_id}, operator_id) + if not ok_op: + return False, err_op or "无权退回该项目交付" + except ImportError: + prec = await sor.sqlExe("SELECT org_id FROM sd_projects WHERE id=${p}$", + {"p": project_id}) + await sor.sqlExe("COMMIT", {}) + proj_org = getattr(prec[0], "org_id", "") if prec else "" + from pipeline_service.human_task_capability import _get_user_org + user_org = await _get_user_org(sor, operator_id) + if proj_org and user_org != proj_org: + return False, "仅同机构用户可退回该项目交付" + + # ① 待办置 rejected + 意见落 result_data(与 tech_template 驳回分支同款约定; + # 注:pipeline_human_tasks 无 updated_at 列) + await sor.sqlExe( + "UPDATE pipeline_human_tasks SET status='rejected', result_data=${rd}$, " + "submitted_by=${u}$, submitted_at=NOW() WHERE id=${h}$ AND status='pending'", + {"rd": json.dumps({"decision": "reject", "comment": comment[:2000]}, + ensure_ascii=False), + "u": operator_id, "h": human_task_id}) + await sor.sqlExe("COMMIT", {}) + await record(sor, project_id, "pipeline_human_tasks", human_task_id, + "delivery_rejected", to_state="rejected", who=operator_id, + detail=comment[:500]) + + # ② 最新标书 passed → rejected(draft/reviewing 本就未过评分,不动) + doc = await _latest_doc(sor, project_id) + doc_id = doc.get("id") or "" + if doc and doc.get("status") == DOC_PASSED: + await sor.sqlExe( + "UPDATE bid_documents SET status=${st}$, updated_at=NOW() " + "WHERE id=${d}$ AND status=${os}$", + {"st": DOC_REJECTED, "d": doc_id, "os": DOC_PASSED}) + await sor.sqlExe("COMMIT", {}) + + # ④ approved 章节重做预算复位(幂等:只动 approved,正在重写的 rejected 章不动) + await sor.sqlExe( + "UPDATE bid_chapters SET revise_count=0 WHERE project_id=${p}$ " + "AND status=${st}$ AND revise_count>0", {"p": project_id, "st": CH_APPROVED}) + await sor.sqlExe("COMMIT", {}) + + # ③ 派 scorer 交付驳回处理任务(在办任务幂等:已有同类在办不重派) + from .bid_flow import _open_tech_tasks + if await _open_tech_tasks(sor, project_id, "bid_delivery_reject") > 0: + return True, "交付已退回(驳回处理任务已在办,勿重复退回)" + prec = await sor.sqlExe("SELECT name FROM sd_projects WHERE id=${p}$", + {"p": project_id}) + await sor.sqlExe("COMMIT", {}) + pname = getattr(prec[0], "name", "") if prec else "" + tid = await create_role_task( + sor, project_id, R_SCORER, + "%s 交付驳回处理(按用户退回意见打回章节)" % pname, + {"stage": "score", "task_kind": "bid_delivery_reject", + "document_id": doc_id, "reject_comment": comment[:60000]}) + await sor.sqlExe("COMMIT", {}) + await record(sor, project_id, "bid_documents", doc_id, "delivery_reject", + from_state=DOC_PASSED, to_state=DOC_REJECTED, + who=operator_id, detail=comment[:480]) + return True, ("交付已退回:标书 v%s 置 rejected,评分员将按你的意见退回问题章节" + "重写(重写→重审→重合成→重评分后自动重发交付确认)。" + % (doc.get("version") or "?")) diff --git a/scripts/load_path.py b/scripts/load_path.py index 4e6f3d1..3eba6fc 100644 --- a/scripts/load_path.py +++ b/scripts/load_path.py @@ -50,6 +50,7 @@ PATHS_LOGINED += [ "/%s/api/bid_task_tree.dspy" % MOD, "/%s/api/bid_task_io.dspy" % MOD, "/%s/api/tech_template_confirm.dspy" % MOD, + "/%s/api/bid_delivery_reject.dspy" % MOD, ] diff --git a/wwwroot/api/bid_delivery_reject.dspy b/wwwroot/api/bid_delivery_reject.dspy new file mode 100644 index 0000000..fea0b54 --- /dev/null +++ b/wwwroot/api/bid_delivery_reject.dspy @@ -0,0 +1,40 @@ +# bid_delivery_reject.dspy - 交付确认待办的「退回交付」端点(2026-09-17) +# +# 背景:bid_delivery_confirm 待办原来只有确认通道(human_task_complete),用户复核 +# 发现问题时无法退回。本端点补退回通道,对齐 tech_template_confirm 的 confirm/reject +# 双通道模式。退回是用户专属裁决(agent 无此工具,不能替用户退回)。 +# +# 入参(form-encoded): +# human_task_id 交付确认待办 id(pipeline_human_tasks.id,task_type=bid_delivery_confirm) +# comment 退回意见(必填:说明标书哪里需要修改,评分员按意见打回章节) +# +# 机制(reject_delivery):待办置 rejected + 意见存 result_data → 最新 passed 标书置 +# rejected → 派 scorer bid_delivery_reject 任务(在办期间对账器不重合成,防绕过)→ +# scorer request_chapter_revise 打回问题章节 → 既有链路自动重写→重审→重合成→重评分 +# → 重发新版交付确认待办。 + +import json as _json + +user_id = await get_user() +if not user_id: + return _json.dumps({"success": False, "error": "未登录"}, ensure_ascii=False) + +human_task_id = str((params_kw or {}).get('human_task_id') or '').strip() +comment = str((params_kw or {}).get('comment') or '').strip() + +if not human_task_id: + return _json.dumps({"success": False, "error": "缺少 human_task_id"}, ensure_ascii=False) +if not comment: + return _json.dumps({"success": False, "error": "退回必须填写意见"}, ensure_ascii=False) + +try: + from pipeline_bidding.bid_score_capability import reject_delivery +except Exception as e: + return _json.dumps({"success": False, + "error": "投标产线评分能力模块不可用:%s" % str(e)[:150]}, + ensure_ascii=False) + +ok, msg = await reject_delivery(human_task_id, comment, operator_id=user_id) +if ok: + return _json.dumps({"success": True, "message": msg}, ensure_ascii=False) +return _json.dumps({"success": False, "error": msg}, ensure_ascii=False)