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:
parent
2fe7c784be
commit
051eae7a25
@ -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 /
|
||||
|
||||
@ -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,
|
||||
"技术方案书已完成,请确认交付",
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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 "?"))
|
||||
|
||||
@ -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,
|
||||
]
|
||||
|
||||
|
||||
|
||||
40
wwwroot/api/bid_delivery_reject.dspy
Normal file
40
wwwroot/api/bid_delivery_reject.dspy
Normal 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 交付确认待办 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)
|
||||
Loading…
x
Reference in New Issue
Block a user