feat(delivery): 交付确认待办补退回通道reject_delivery(2026-09-17用户报障:待办只有确认无退回)——用户专属端点bid_delivery_reject.dspy(agent无此工具,退回是用户裁决)+权限复用_check_confirm_operator;机制:待办rejected+意见存result_data(tech_template驳回同款约定)→passed标书置rejected→派scorer bid_delivery_reject任务(带reject_comment)按意见request_chapter_revise打回章节→既有链路自动重写重审重合成重评分重发交付确认;approved章节revise_count复位(用户级退回=新修订预算,不复位则烧满max_revise的章被C段拦死);E/T6段配套门禁:驳回任务在办期间不派合成(防章节未打回就抢先重合成同内容新版绕过用户意见);T7交付待办补cancelled/rejected豁免重发。顺带修reject_chapter两处:①_sync_mirror死代码(return后永不执行,退回章节镜像不同步)②补标书联动作废(与review_chapter退回分支同款缺口:标书passed时人工/scorer退回章节,重写内容永远进不了交付物+旧交付待办僵尸)

This commit is contained in:
yumoqing 2026-09-17 22:25:46 +08:00
parent 2fe7c784be
commit 051eae7a25
6 changed files with 199 additions and 2 deletions

View File

@ -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 /

View File

@ -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-17bid_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,
"技术方案书已完成,请确认交付",

View File

@ -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 退回分支同款)──
# 本工具被人工/scorerrequest_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):

View File

@ -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_datatech_template 驳回同款约定
最新 passed 标书置 rejected否则 G 段按 passed 立刻重发确认待办
scorer交付驳回处理任务task_kind=bid_delivery_rejectparams
reject_commentscorer 按用户意见 request_chapter_revise 退回问题章节
之后 CDEFG 既有链路自动重写重审重合成重评分重发交付确认
全部 approved 章节 revise_count 复位用户级退回=新一轮修订预算不复位则
上轮烧满 max_revise 的章节被 C 段门禁拦死退回即死锁对齐 C1 段人工
完成待办后复位 revise_count 的既有语义
E 段配套门禁bid_flowbid_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 → rejecteddraft/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 "?"))

View File

@ -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,
]

View File

@ -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 交付确认待办 idpipeline_human_tasks.idtask_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)