- 新增 /pipeline-bidding/agent/index.ui 多 session 会话界面(复用通用 agent) - bid_ability 砍 fetch_tenders/import_tender/list_tenders/tender_detail/summarize/set_fields/submit/approve/reject/archive/reopen 等邮件采集+审批工具 - create_bid_project 改为直接创建项目+发上传招标文件任务(不依赖 tender) - 删除招标信息员角色,菜单改招标文件/评分项/章节/资质/知识库/标书 - bid_flow 去掉审批门禁,无招标文件则自动发上传任务作为产线起点
462 lines
22 KiB
Python
462 lines
22 KiB
Python
"""招标信息能力:采集 / 摘要要素抽取 / 审批 / 存档作废 / 立项 / 人员 / 等待招标文件。
|
||
|
||
对应业务:从邮箱获取招标信息 → 内容摘要(投标开始时间/项目周期/招标金额…)→ 走审批流程
|
||
→ 不通过则存档作废;通过则创建项目、设定参与人员、等待上传招标文件。
|
||
|
||
状态机规范见技能 `tender`;本模块只固化操作原语(CAS 迁移 + 审计),合法性由 LLM 读技能判断。
|
||
"""
|
||
|
||
import json
|
||
import logging
|
||
|
||
from .bid_common import (
|
||
get_db, new_id, rec_to_dict, rows_to_dicts, json_loads, to_float, to_int,
|
||
llm_json, record, create_human_task, find_human_task, get_project,
|
||
PIPELINE_ID, TD_NEW, TD_SUMMARIZED, TD_PENDING_APPROVAL, TD_APPROVED,
|
||
TD_REJECTED, TD_ARCHIVED, HT_TENDER_APPROVAL, HT_TENDER_FILE,
|
||
)
|
||
|
||
logger = logging.getLogger("pipeline.bidding.tender")
|
||
|
||
TABLE = "bid_tenders"
|
||
|
||
SUMMARY_PROMPT = """你是招标信息分析员。下面是一封招标邮件的主题与正文,请抽取结构化要素。
|
||
|
||
【邮件主题】
|
||
__SUBJECT__
|
||
|
||
【邮件正文】
|
||
__BODY__
|
||
|
||
严格只输出一个 JSON 对象,字段如下(缺失或正文未提及的填空字符串,不要编造):
|
||
{
|
||
"title": "项目名称",
|
||
"purchaser": "招标人/采购人单位名称",
|
||
"tender_no": "招标编号",
|
||
"industry": "所属行业",
|
||
"summary": "内容摘要,200字以内,说明采购标的、范围、主要要求",
|
||
"bid_start_at": "投标开始/获取招标文件时间 YYYY-MM-DD",
|
||
"bid_deadline": "投标截止时间 YYYY-MM-DD",
|
||
"project_period": "项目周期,如 12个月 / 180日历天",
|
||
"budget_amount": "招标金额(仅数字,人民币元)",
|
||
"deposit_amount": "投标保证金(仅数字,人民币元)",
|
||
"qualification_req": "投标人资格/资质要求原文摘要",
|
||
"key_dates": [{"name": "节点名称", "date": "YYYY-MM-DD"}],
|
||
"risk_note": "风险提示:时间紧、资质缺口、否决项等;无则空"
|
||
}
|
||
注意:金额只填数字(如 1200000),不带单位和千分位;日期只填 YYYY-MM-DD;无法确定一律留空。"""
|
||
|
||
|
||
async def _get_tender(sor, tender_id):
|
||
recs = await sor.sqlExe("SELECT * FROM " + TABLE + " WHERE id=${tid}$", {"tid": tender_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
return rec_to_dict(recs[0]) if recs else {}
|
||
|
||
|
||
async def _set_status(sor, tender_id, from_status, to_status, action,
|
||
who=None, agent_id=None, detail=None, extra=None):
|
||
"""CAS 状态迁移(from_status 为 None 时不校验来源状态)+ 审计。"""
|
||
data = dict(extra or {})
|
||
data["status"] = to_status
|
||
sets = ", ".join("%s=${%s}$" % (k, k) for k in data)
|
||
p = dict(data)
|
||
p["tid"] = tender_id
|
||
sql = "UPDATE " + TABLE + " SET " + sets + ", updated_at=NOW() WHERE id=${tid}$"
|
||
if from_status:
|
||
sql += " AND status=${_from}$"
|
||
p["_from"] = from_status
|
||
await sor.sqlExe(sql, p)
|
||
cur = await _get_tender(sor, tender_id)
|
||
if not cur:
|
||
return False, "招标信息不存在: %s" % tender_id
|
||
if cur.get("status") != to_status:
|
||
return False, ("状态迁移失败(CAS): 期望 from=%s 实际 status=%s"
|
||
% (from_status, cur.get("status")))
|
||
await record(sor, cur.get("project_id") or tender_id, TABLE, tender_id, action,
|
||
from_state=from_status, to_state=to_status, who=who,
|
||
agent_id=agent_id, detail=detail)
|
||
return True, to_status
|
||
|
||
|
||
# ══════════════ 采集 ══════════════
|
||
|
||
async def fetch_tenders(org_id="", account_id="", days=None, who=None, agent_id=None):
|
||
"""从配置的招标邮箱采集招标信息入库(去重)。返回 (ok, message)。"""
|
||
from .bid_mail import fetch_tenders_to_db
|
||
return await fetch_tenders_to_db(org_id=org_id, account_id=account_id, days=days)
|
||
|
||
|
||
async def import_tender(title, content, purchaser="", tender_no="", org_id="0",
|
||
who=None, agent_id=None, created_by=""):
|
||
"""手工导入招标信息(邮箱不可用时的备用入口)。返回 (ok, tender_id)。"""
|
||
if not title or not str(title).strip():
|
||
return False, "缺少 title"
|
||
db, dbname = get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
tid = new_id()
|
||
await sor.C(TABLE, {
|
||
"id": tid, "org_id": org_id or "0", "source": "manual",
|
||
"title": str(title).strip()[:250], "mail_subject": str(title).strip()[:480],
|
||
"raw_content": content or "", "purchaser": purchaser or "",
|
||
"tender_no": tender_no or "", "status": TD_NEW,
|
||
"created_by": created_by or (who or ""),
|
||
})
|
||
await record(sor, tid, TABLE, tid, "import", to_state=TD_NEW,
|
||
who=who, agent_id=agent_id)
|
||
return True, tid
|
||
|
||
|
||
async def list_tenders(status="", org_id="", limit=50):
|
||
"""列出招标信息。"""
|
||
db, dbname = get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
sql = ("SELECT id, title, purchaser, tender_no, status, bid_start_at, bid_deadline, "
|
||
"project_period, budget_amount, project_id, received_at "
|
||
"FROM " + TABLE + " WHERE 1=1")
|
||
p = {}
|
||
if status:
|
||
sql += " AND status=${st}$"
|
||
p["st"] = status
|
||
if org_id:
|
||
sql += " AND org_id=${oid}$"
|
||
p["oid"] = org_id
|
||
sql += " ORDER BY received_at DESC, created_at DESC LIMIT " + str(to_int(limit, 50))
|
||
recs = await sor.sqlExe(sql, p)
|
||
await sor.sqlExe("COMMIT", {})
|
||
return rows_to_dicts(recs)
|
||
|
||
|
||
async def tender_detail(tender_id):
|
||
"""招标信息详情(含正文,供分析用)。"""
|
||
db, dbname = get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
d = await _get_tender(sor, tender_id)
|
||
if not d:
|
||
return "招标信息不存在: %s" % tender_id
|
||
body = d.get("raw_content") or ""
|
||
d["raw_content"] = body[:8000]
|
||
return json.dumps(d, ensure_ascii=False, default=str)
|
||
|
||
|
||
# ══════════════ 摘要与要素抽取 ══════════════
|
||
|
||
async def summarize_tender(tender_id, model_name="", who=None, agent_id=None):
|
||
"""LLM 抽取摘要 + 投标开始时间/项目周期/招标金额等要素,落库并置 summarized。"""
|
||
db, dbname = get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
t = await _get_tender(sor, tender_id)
|
||
if not t:
|
||
return False, "招标信息不存在: %s" % tender_id
|
||
body = (t.get("raw_content") or "")[:20000]
|
||
if not body.strip():
|
||
return False, "招标信息正文为空,无法摘要(请先补充 raw_content 或上传招标文件)"
|
||
prompt = (SUMMARY_PROMPT
|
||
.replace("__SUBJECT__", (t.get("mail_subject") or t.get("title") or "")[:500])
|
||
.replace("__BODY__", body))
|
||
data, err = await llm_json(prompt, model_name=model_name, retries=1)
|
||
if data is None:
|
||
return False, "LLM 摘要失败: %s" % err
|
||
extra = {}
|
||
for k in ("title", "purchaser", "tender_no", "industry", "summary",
|
||
"project_period", "qualification_req", "risk_note"):
|
||
v = data.get(k)
|
||
if v:
|
||
extra[k] = str(v)[:2000] if k in ("summary", "qualification_req", "risk_note") else str(v)[:250]
|
||
for k in ("bid_start_at", "bid_deadline"):
|
||
v = (data.get(k) or "").strip()
|
||
if v and len(v) >= 8:
|
||
extra[k] = v[:10]
|
||
for k in ("budget_amount", "deposit_amount"):
|
||
v = data.get(k)
|
||
if v not in (None, "", "无"):
|
||
amt = to_float(str(v).replace(",", "").replace("元", "").strip(), 0.0)
|
||
if amt > 0:
|
||
extra[k] = amt
|
||
kd = data.get("key_dates")
|
||
if kd:
|
||
extra["key_dates"] = json.dumps(kd, ensure_ascii=False)[:4000]
|
||
|
||
db, dbname = get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
ok, msg = await _set_status(sor, tender_id, None, TD_SUMMARIZED, "summarize",
|
||
who=who, agent_id=agent_id, extra=extra)
|
||
if not ok:
|
||
return False, msg
|
||
return True, ("摘要完成: %s | 投标开始=%s 截止=%s 周期=%s 金额=%s"
|
||
% (extra.get("title", t.get("title", "")),
|
||
extra.get("bid_start_at", ""), extra.get("bid_deadline", ""),
|
||
extra.get("project_period", ""), extra.get("budget_amount", "")))
|
||
|
||
|
||
async def set_tender_fields(tender_id, title="", purchaser="", tender_no="",
|
||
bid_start_at="", bid_deadline="", project_period="",
|
||
budget_amount="", deposit_amount="", summary="",
|
||
qualification_req="", who=None, agent_id=None):
|
||
"""人工/agent 修正招标要素(LLM 抽错时用)。"""
|
||
vals = {"title": title, "purchaser": purchaser, "tender_no": tender_no,
|
||
"bid_start_at": bid_start_at, "bid_deadline": bid_deadline,
|
||
"project_period": project_period, "summary": summary,
|
||
"qualification_req": qualification_req}
|
||
data = {k: v for k, v in vals.items() if v not in (None, "")}
|
||
for k, v in (("budget_amount", budget_amount), ("deposit_amount", deposit_amount)):
|
||
if v not in (None, ""):
|
||
data[k] = to_float(str(v).replace(",", ""), 0.0)
|
||
if not data:
|
||
return False, "未提供任何要修改的字段"
|
||
db, dbname = get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
sets = ", ".join("%s=${%s}$" % (k, k) for k in data)
|
||
p = dict(data)
|
||
p["tid"] = tender_id
|
||
await sor.sqlExe("UPDATE " + TABLE + " SET " + sets + ", updated_at=NOW() WHERE id=${tid}$", p)
|
||
await sor.sqlExe("COMMIT", {})
|
||
await record(sor, tender_id, TABLE, tender_id, "set_fields",
|
||
who=who, agent_id=agent_id, detail=json.dumps(list(data), ensure_ascii=False))
|
||
return True, "已更新字段: %s" % ", ".join(data)
|
||
|
||
|
||
# ══════════════ 审批流程 ══════════════
|
||
|
||
async def submit_tender_approval(tender_id, assignee_role="owner.superuser",
|
||
assignee_id="", note="", who=None, agent_id=None):
|
||
"""提交招标信息审批:创建人类审批任务 + 状态置 pending_approval。"""
|
||
db, dbname = get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
t = await _get_tender(sor, tender_id)
|
||
if not t:
|
||
return False, "招标信息不存在: %s" % tender_id
|
||
if t.get("status") == TD_PENDING_APPROVAL:
|
||
return True, "已在审批中(human_task=%s)" % (t.get("approval_task_id") or "")
|
||
if t.get("status") not in (TD_NEW, TD_SUMMARIZED):
|
||
return False, "当前状态 %s 不可提交审批(需 new/summarized)" % t.get("status")
|
||
title = "招标信息审批:%s" % (t.get("title") or t.get("mail_subject") or tender_id)
|
||
desc_lines = [
|
||
"招标人:%s" % (t.get("purchaser") or "未识别"),
|
||
"招标编号:%s" % (t.get("tender_no") or "未识别"),
|
||
"投标开始:%s 截止:%s" % (t.get("bid_start_at") or "-", t.get("bid_deadline") or "-"),
|
||
"项目周期:%s" % (t.get("project_period") or "-"),
|
||
"招标金额:%s" % (t.get("budget_amount") or "-"),
|
||
"保证金:%s" % (t.get("deposit_amount") or "-"),
|
||
"资质要求:%s" % ((t.get("qualification_req") or "-")[:500]),
|
||
"摘要:%s" % ((t.get("summary") or "-")[:1000]),
|
||
"风险提示:%s" % ((t.get("risk_note") or "-")[:500]),
|
||
]
|
||
if note:
|
||
desc_lines.append("提交说明:%s" % note)
|
||
desc_lines.append("")
|
||
desc_lines.append("请决策:通过 → 立项投标;不通过 → 该招标信息存档作废。")
|
||
hid = await create_human_task(
|
||
sor, "", HT_TENDER_APPROVAL, title, "\n".join(desc_lines),
|
||
assignee_role=assignee_role, assignee_id=assignee_id,
|
||
form_schema={"tender_id": tender_id, "decision": ["approve", "reject"]})
|
||
ok, msg = await _set_status(sor, tender_id, None, TD_PENDING_APPROVAL,
|
||
"submit_approval", who=who, agent_id=agent_id,
|
||
extra={"approval_task_id": hid})
|
||
if not ok:
|
||
return False, msg
|
||
return True, "已提交审批(human_task=%s,指派 %s)" % (hid, assignee_role)
|
||
|
||
|
||
async def approve_tender(tender_id, comment="", who=None, agent_id=None,
|
||
auto_create_project=True):
|
||
"""审批通过:pending_approval → approved;默认自动立项 + 发上传招标文件任务。"""
|
||
db, dbname = get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
ok, msg = await _set_status(sor, tender_id, TD_PENDING_APPROVAL, TD_APPROVED,
|
||
"approve", who=who, agent_id=agent_id, detail=comment)
|
||
if not ok:
|
||
ok, msg = await _set_status(sor, tender_id, TD_SUMMARIZED, TD_APPROVED,
|
||
"approve", who=who, agent_id=agent_id, detail=comment)
|
||
if not ok:
|
||
return False, msg
|
||
await _close_approval_task(sor, tender_id, "done", comment)
|
||
if not auto_create_project:
|
||
return True, "审批通过(未自动立项)"
|
||
ok2, msg2 = await create_bid_project(tender_id, who=who, agent_id=agent_id)
|
||
if not ok2:
|
||
return True, "审批通过,但立项失败: %s" % msg2
|
||
return True, "审批通过并已立项: %s" % msg2
|
||
|
||
|
||
async def reject_tender(tender_id, comment="", who=None, agent_id=None):
|
||
"""审批不通过:pending_approval → rejected → 立即存档作废(业务要求)。"""
|
||
db, dbname = get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
ok, msg = await _set_status(sor, tender_id, TD_PENDING_APPROVAL, TD_REJECTED,
|
||
"reject", who=who, agent_id=agent_id, detail=comment)
|
||
if not ok:
|
||
return False, msg
|
||
await _close_approval_task(sor, tender_id, "rejected", comment)
|
||
ok2, msg2 = await _set_status(
|
||
sor, tender_id, TD_REJECTED, TD_ARCHIVED, "archive",
|
||
who=who, agent_id=agent_id, detail="审批不通过自动存档作废",
|
||
extra={"archive_reason": ("审批不通过:" + (comment or ""))[:480]})
|
||
if not ok2:
|
||
return False, "已驳回但存档失败: %s" % msg2
|
||
return True, "审批不通过,招标信息已存档作废"
|
||
|
||
|
||
async def archive_tender(tender_id, reason="", who=None, agent_id=None):
|
||
"""存档作废(任何状态 → archived)。"""
|
||
db, dbname = get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
return await _set_status(sor, tender_id, None, TD_ARCHIVED, "archive",
|
||
who=who, agent_id=agent_id, detail=reason,
|
||
extra={"archive_reason": (reason or "")[:480]})
|
||
|
||
|
||
async def reopen_tender(tender_id, reason="", who=None, agent_id=None):
|
||
"""存档/驳回后重新评估:archived/rejected → summarized。"""
|
||
db, dbname = get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
ok, msg = await _set_status(sor, tender_id, TD_ARCHIVED, TD_SUMMARIZED, "reopen",
|
||
who=who, agent_id=agent_id, detail=reason)
|
||
if ok:
|
||
return ok, msg
|
||
return await _set_status(sor, tender_id, TD_REJECTED, TD_SUMMARIZED, "reopen",
|
||
who=who, agent_id=agent_id, detail=reason)
|
||
|
||
|
||
async def _close_approval_task(sor, tender_id, status, comment=""):
|
||
"""关闭招标审批人类任务(同步审批结论)。"""
|
||
t = await _get_tender(sor, tender_id)
|
||
hid = t.get("approval_task_id") or ""
|
||
if not hid:
|
||
return
|
||
await sor.sqlExe(
|
||
"UPDATE pipeline_human_tasks SET status=${st}$, qc_comment=${cm}$, "
|
||
"submitted_at=NOW() WHERE id=${hid}$ AND status='pending'",
|
||
{"st": status, "cm": (comment or "")[:480], "hid": hid})
|
||
await sor.sqlExe("COMMIT", {})
|
||
|
||
|
||
# ══════════════ 立项 / 人员 / 招标文件 ══════════════
|
||
|
||
async def create_bid_project(name, description="", org_id="", created_by="",
|
||
who=None, agent_id=None):
|
||
"""直接创建投标项目(不依赖招标信息前置)+ 发「上传招标文件」任务。
|
||
|
||
产线入口改为「从上传招标文件开始」后,立项不再经过邮件采集/审批,
|
||
而是用户/agent 直接创建投标项目,随后上传招标文件进入解析。
|
||
"""
|
||
if not name or not str(name).strip():
|
||
return False, "缺少项目名称(name)"
|
||
db, dbname = get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
pid = new_id()
|
||
pname = str(name).strip()[:190]
|
||
await sor.C("sd_projects", {
|
||
"id": pid,
|
||
"name": pname,
|
||
"description": (description or "")[:4000],
|
||
"project_type": "bidding",
|
||
"pipeline_id": PIPELINE_ID,
|
||
"status": "in_progress",
|
||
"org_id": org_id or "0",
|
||
"created_by": created_by or (who or "agent.pm"),
|
||
})
|
||
await sor.sqlExe("COMMIT", {})
|
||
await record(sor, pid, "sd_projects", pid, "create_bid_project",
|
||
to_state="in_progress", who=who, agent_id=agent_id,
|
||
detail="direct create (no tender)")
|
||
hid = await create_human_task(
|
||
sor, pid, HT_TENDER_FILE,
|
||
"上传招标文件:%s" % pname,
|
||
("项目已创建,请上传招标文件(含答疑/补遗文件)。\n"
|
||
"上传后系统自动进入招标文件解析(抽取评分项、得分规则、所需资质、投标文件要求、章节骨架)。\n"
|
||
"上传入口:投标产线 → 招标文件(bid_tender_files)。"),
|
||
assignee_role="owner.superuser",
|
||
form_schema={"project_id": pid})
|
||
return True, "project_id=%s(已发上传招标文件任务 %s)" % (pid, hid)
|
||
|
||
|
||
async def assign_members(project_id, members="", who=None, agent_id=None):
|
||
"""设定参与人员。members 支持 JSON 数组或 "姓名:角色,姓名:角色" 简写。"""
|
||
if not project_id:
|
||
return False, "缺少 project_id"
|
||
items = []
|
||
ms = members
|
||
if isinstance(ms, str) and ms.strip():
|
||
parsed = json_loads(ms, None)
|
||
if isinstance(parsed, list):
|
||
items = parsed
|
||
else:
|
||
for seg in ms.split(","):
|
||
seg = seg.strip()
|
||
if not seg:
|
||
continue
|
||
if ":" in seg or ":" in seg:
|
||
nm, rl = seg.replace(":", ":").split(":", 1)
|
||
items.append({"user_name": nm.strip(), "member_role": rl.strip()})
|
||
else:
|
||
items.append({"user_name": seg, "member_role": "writer"})
|
||
elif isinstance(ms, list):
|
||
items = ms
|
||
if not items:
|
||
return False, "未解析出参与人员(示例:张三:负责人,李四:writer)"
|
||
db, dbname = get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
n = 0
|
||
for it in items:
|
||
if not isinstance(it, dict):
|
||
continue
|
||
nm = str(it.get("user_name") or it.get("name") or "").strip()
|
||
if not nm:
|
||
continue
|
||
exist = await sor.sqlExe(
|
||
"SELECT id FROM bid_members WHERE project_id=${pid}$ AND user_name=${nm}$ LIMIT 1",
|
||
{"pid": project_id, "nm": nm})
|
||
if exist:
|
||
continue
|
||
await sor.C("bid_members", {
|
||
"id": new_id(), "project_id": project_id,
|
||
"user_id": str(it.get("user_id") or ""), "user_name": nm[:120],
|
||
"member_role": str(it.get("member_role") or "writer")[:60],
|
||
"duty": str(it.get("duty") or "")[:480],
|
||
"status": "active", "created_by": who or "",
|
||
})
|
||
n += 1
|
||
await sor.sqlExe("COMMIT", {})
|
||
await record(sor, project_id, "bid_members", project_id, "assign_members",
|
||
who=who, agent_id=agent_id, detail="新增 %d 人" % n)
|
||
return True, "已设定参与人员 %d 人" % n
|
||
|
||
|
||
async def list_members(project_id):
|
||
db, dbname = get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
recs = await sor.sqlExe(
|
||
"SELECT user_name, member_role, duty, status FROM bid_members "
|
||
"WHERE project_id=${pid}$ ORDER BY created_at", {"pid": project_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
return rows_to_dicts(recs)
|
||
|
||
|
||
async def request_tender_file(project_id, note="", who=None, agent_id=None):
|
||
"""(重新)发布「等待上传招标文件」人类任务。"""
|
||
db, dbname = get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
p = await get_project(sor, project_id)
|
||
if not p:
|
||
return False, "项目不存在: %s" % project_id
|
||
exist = await find_human_task(sor, project_id, HT_TENDER_FILE, status="pending")
|
||
if exist:
|
||
return True, "已有待办上传任务: %s" % exist.get("id")
|
||
hid = await create_human_task(
|
||
sor, project_id, HT_TENDER_FILE,
|
||
"上传招标文件:%s" % p.get("name", ""),
|
||
note or "请上传招标文件(含答疑/补遗),上传后自动进入解析阶段。")
|
||
await sor.sqlExe("COMMIT", {})
|
||
return True, "已发布上传招标文件任务: %s" % hid
|
||
|
||
|
||
async def list_tender_files(project_id):
|
||
db, dbname = get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
recs = await sor.sqlExe(
|
||
"SELECT id, file_name, file_type, extract_status, file_size, created_at "
|
||
"FROM bid_tender_files WHERE project_id=${pid}$ ORDER BY created_at",
|
||
{"pid": project_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
return rows_to_dicts(recs)
|