diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..885f4b8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +*.egg-info/ +build/ +dist/ diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..0741b21 --- /dev/null +++ b/build.sh @@ -0,0 +1,113 @@ +#!/bin/bash +# pipeline-bidding 一键部署脚本(在宿主应用根目录执行或从本目录定位宿主)。 +# 职责:建表(运行期禁止 schema 变更,建表只在部署期)+ 种子数据(appcodes + pipelines 产线记录) +# + wwwroot 软链 + pip install 本包。 +# 参考模式:app_audit/build.sh + sage-database-design-conventions 的种子数据步骤。 + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# 定位宿主应用根目录(含 wwwroot + py3) +APP_ROOT="" +for candidate in "$SCRIPT_DIR/../../pipeline-app" "$SCRIPT_DIR/../.." "$HOME/work/pipeline/pipeline-app"; do + if [ -d "$candidate/wwwroot" ] && [ -d "$candidate/py3" ]; then + APP_ROOT="$(cd "$candidate" && pwd)" + break + fi +done +if [ -z "$APP_ROOT" ]; then + echo "ERROR: host app (pipeline-app) root not found" + exit 1 +fi +PYTHON="$APP_ROOT/py3/bin/python" +echo "Host app root: $APP_ROOT" + +# ── 1. 建表(幂等:CREATE TABLE IF NOT EXISTS 由 json2ddl 产物改造)── +cd "$SCRIPT_DIR" +"$PYTHON" - <<'PYEOF' +import re, sys, os +ddl = open('mysql.ddl.sql', encoding='utf-8').read() +# json2ddl 产物含 drop table if exists,部署期改为 IF NOT EXISTS 幂等建表 +ddl = re.sub(r'drop table if exists (\w+);', '', ddl) +ddl = re.sub(r'CREATE TABLE (\w+)\s*\(', r'CREATE TABLE IF NOT EXISTS \1 (', ddl) +open('/tmp/pipeline_bidding_ddl.sql', 'w', encoding='utf-8').write(ddl) +print('DDL prepared:', ddl.count('CREATE TABLE IF NOT EXISTS'), 'tables') +PYEOF + +"$PYTHON" - <<'PYEOF' +import sys, os, subprocess +sys.path.insert(0, os.getcwd()) +from appPublic.jsonConfig import getConfig +from appPublic.aes import aes_decode_b64 +cfg = getConfig('.', {'workdir': '.'}) +kw = cfg.databases['pipeline'].kwargs +pwd = aes_decode_b64(cfg.password_key, kw.password) +with open('/tmp/pipeline_bidding_ddl.sql', 'rb') as f: + r = subprocess.run(['mysql', '-h', str(kw.host), '-P', str(kw.port), + '-u', str(kw.user), '-p%s' % pwd, str(kw.db)], + stdin=f, capture_output=True) +if r.returncode == 0: + print('tables applied') +else: + print('ERROR:', r.stderr.decode('utf-8', 'replace').strip()[:300]) + sys.exit(1) +PYEOF + +# ── 2. 种子数据:appcodes 字典 + pipelines 产线记录(数据源 init/data.json,幂等)── +cd "$APP_ROOT" +"$PYTHON" - "$SCRIPT_DIR" <<'PYEOF' +import sys, os, json, asyncio +CDIR = sys.argv[1] +sys.path.insert(0, os.getcwd()) +from appPublic.jsonConfig import getConfig +from sqlor.dbpools import DBPools +from appPublic.uniqueID import getID + +data = json.load(open(os.path.join(CDIR, 'init', 'data.json'))) + +async def seed(): + config = getConfig('.') + db = DBPools(config.databases) + async with db.sqlorContext('pipeline') as sor: + # pipelines 产线主记录(checklist 第 0 步) + for pl in data.get('pipelines', []): + recs = await sor.sqlExe("SELECT id FROM pipelines WHERE id=${i}$", {"i": pl['id']}) + if not recs: + await sor.C('pipelines', { + 'id': pl['id'], 'name': pl['name'], + 'description': pl.get('description', ''), + 'pipeline_type': pl.get('pipeline_type', 'bidding'), + 'version': '1.0.0', 'status': pl.get('status', 'published'), + 'org_id': pl.get('org_id', '0'), 'created_by': 'system', + }) + print('pipeline registered:', pl['id']) + # appcodes 字典 + for ac in data.get('appcodes', []): + pid = ac['parentid'] + rows = await sor.R('appcodes', {'id': pid}) + if not rows: + await sor.C('appcodes', {'id': pid, 'name': ac.get('parentname', pid), + 'hierarchy_flg': '0'}) + for item in ac.get('items', []): + chk = await sor.sqlExe( + "SELECT id FROM appcodes_kv WHERE parentid=${p}$ AND k=${k}$", + {'p': pid, 'k': item['k']}) + if not chk: + await sor.C('appcodes_kv', {'id': getID(), 'parentid': pid, + 'k': item['k'], 'v': item['v']}) + await sor.sqlExe("COMMIT", {}) + print('seeded %d code groups' % len(data.get('appcodes', []))) + +asyncio.run(seed()) +PYEOF + +# ── 3. wwwroot 软链 + pip install ── +rm -f "$APP_ROOT/wwwroot/pipeline-bidding" +ln -sf "$SCRIPT_DIR/wwwroot" "$APP_ROOT/wwwroot/pipeline-bidding" +echo "wwwroot linked: pipeline-bidding" + +"$APP_ROOT/py3/bin/pip" install "$SCRIPT_DIR/" 2>&1 | tail -1 + +echo "pipeline-bidding module deploy complete." +echo "后续(宿主负责):app 入口 import load_pipeline_bidding;RBAC 用 scripts/load_path.py;i18n 用 merge_i18n.py。" diff --git a/docs/DESIGN.md b/docs/DESIGN.md new file mode 100644 index 0000000..386bc70 --- /dev/null +++ b/docs/DESIGN.md @@ -0,0 +1,227 @@ +# 投标产线(bidding_general)设计文档 + +> 对照开发产线(`sdlc_general`)设计,复用平台通用内核(项目/会话/任务链/人类任务/交付件/问答冒泡/技能分层), +> 只增量定义投标产线自己的 **阶段、角色、工具、技能、任务流转规则、交付件**。 + +版本:v1.0.0  创建:2026-08-26 + +--- + +## 一、业务流程(需求原文映射) + +| # | 业务动作 | 承担者 | 落库 | +|---|---|---|---| +| 1 | 从 yumoqing@opencomputing.cn(阿里云企业邮)获取招标信息 | agent.tender_scout | bid_tenders | +| 2 | 对每条招标信息内容摘要:投标开始时间、项目周期、招标金额等 | agent.tender_scout | bid_tenders.summary/bid_start_at/project_period/budget_amount | +| 3 | 走审批流程 | 人(human task + 可选钉钉审批) | pipeline_human_tasks(task_type=tender_approval) | +| 4 | 审批不通过 → 招标信息存档作废 | 机制 | bid_tenders.status=archived | +| 5 | 审批通过 → 创建项目 | 机制/主 agent | sd_projects(pipeline_id=bidding_general) | +| 6 | 设定参与人员 | 主 agent / 人 | bid_members | +| 7 | 等待上传招标文件 | 人(阻塞门禁) | pipeline_human_tasks(tender_file_upload) + bid_tender_files | +| 8 | 从招标文件抽取评分项及得分规则 | agent.tender_analyst | bid_scoring_items | +| 9 | 抽取所需资质 | agent.tender_analyst | bid_qualifications | +| 10 | 抽取投标文件要求(章节结构/格式/份数/密封) | agent.tender_analyst | bid_doc_requirements | +| 11 | 从公司投标知识库获取所需资质文档 | agent.bid_prep | bid_qualifications.kb_doc_id ← bid_kb_docs | +| 12 | 发布人类应提供的文件清单 | agent.bid_prep(阻塞门禁) | pipeline_human_tasks(human_docs_request) | +| 13 | 按投标文件要求分章节编写投标文件 | agent.bid_writer | bid_chapters | +| 14 | 每章节完成后对照招标/投标文件要求评审,分数不够给改进意见重做 | agent.bid_reviewer | bid_reviews(scope=chapter) → 章节循环 | +| 15 | 合成标书 | agent.bid_compositor | bid_documents(docx) | +| 16 | 对整个标书评审 + 按评分项和得分规则打分 | agent.bid_scorer | bid_reviews(scope=whole) + bid_scores | +| 17 | 不合适 → 改进意见 → 修改问题章节 → 评审 → …… 直到得分通过 | 机制(整书循环) | bid_scores.target_chapter_id → 章节回 rejected | + +--- + +## 二、阶段与角色(对照开发产线) + +| 开发产线 | 投标产线 | 角色规范名 | 职责 | next_role | +|---|---|---|---|---| +| requirement 需求分析 | 招标情报 | `agent.tender_scout` | 收邮件、去重入库、摘要与要素抽取、提交审批 | `agent.tender_analyst` | +| —(新增门禁) | 招标审批 | 人类任务 | 审批通过立项 / 不通过存档作废 | — | +| design 设计 | 招标文件解析 | `agent.tender_analyst` | 抽取评分项+得分规则、所需资质、投标文件要求,产出章节骨架 | `agent.bid_prep` | +| —(新增) | 资料准备 | `agent.bid_prep` | 知识库资质匹配、缺失项发布人类文件清单 | `agent.bid_writer` | +| develop 开发 | 章节编写 | `agent.bid_writer` | 按章节骨架逐章写作(一章一任务) | `agent.bid_reviewer` | +| test 测试 | 章节评审 | `agent.bid_reviewer` | 对照招标要求+得分规则逐章打分,不达标退回重写 | `agent.bid_compositor` | +| —(新增) | 标书合成 | `agent.bid_compositor` | 合成 docx(封面/目录/页码/偏离表),产出交付件 | `agent.bid_scorer` | +| deploy 部署 | 全书评分 | `agent.bid_scorer` | 按评分项+得分规则给整书打分、生成改进意见并映射章节 | ""(终结→人类交付确认) | +| agent.pm | 同名复用 | `agent.pm` | 任务审核/派发/验收 | — | +| agent.qc | 同名复用 | `agent.qc` | 交付件合规检查 | — | + +> 角色命名遵守平台规范:agent 角色 = `agent.{role}`,人类角色 = RBAC `{orgtype}.{role}`。 +> 同名角色跨产线隔离(按 pipeline_id 查 RoleSpec),`agent.pm`/`agent.qc` 在两产线各有自己的技能与提示词。 + +--- + +## 三、任务流转规则 + +### 3.1 主链(机制驱动,非 LLM 驱动) + +``` +[邮箱] --fetch_tenders--> bid_tenders(new) + --summarize_tender--> summarized + --submit_tender_approval--> pending_approval ┌── reject ──> archived(作废) ✗终止 + └── approve ──> approved + --create_bid_project--> sd_projects + assign_members + --request_tender_file--> [人类任务:上传招标文件] (阻塞) + ↓ 上传完成 + agent.tender_analyst → 评分项 / 资质 / 投标文件要求 / 章节骨架 + ↓ approved (PM) + agent.bid_prep → 知识库资质匹配 + [人类任务:应提供文件清单](阻塞) + ↓ approved + agent.bid_writer → 逐章写作(每章一个任务,可并行受并发上限约束) + ↓ 章节 written + agent.bid_reviewer → 章节评审打分 + ↓ 全部章节 approved + agent.bid_compositor → 合成标书 docx + ↓ + agent.bid_scorer → 整书评分 + ↓ 通过 + [人类任务:交付确认] → 项目 completed +``` + +### 3.2 循环一:章节级评审回退 + +- 触发:`review_chapter` 判定 `score / max_score < chapter_pass_ratio`(默认 0.8) +- 动作:章节 `status=rejected` + `review_comment` 落库 + `revise_count += 1` +- 机制:`bid_reviewer` 任务 approved 后,若存在 rejected 章节 → 自动派生 `agent.bid_writer` 重写任务(任务标题带章节号,params 带 `chapter_id` + `improvement`) +- 上限:`revise_count > bid_max_revise`(默认 3)→ 抛人类介入(fault_report + 人类任务),不无限重做 + +### 3.3 循环二:整书评分回退 + +- 触发:`score_bid` 总分率 `< bid_pass_ratio`(默认 0.85),或存在否决项(`is_veto`)未响应 +- 动作:`bid_scores` 逐评分项落分 + `improvement` + `target_chapter_id` +- 机制:把 `target_chapter_id` 命中的章节置 `rejected` → 派生 `bid_writer` 任务 → 重写 → 章节评审 → 全部通过 → 重新合成 → 重新评分 +- 轮次记录 `bid_reviews.round`,上限 `bid_max_round`(默认 3)→ 抛人类介入 + +### 3.4 阻塞门禁(人类任务) + +| task_type | 语义 | 阻塞范围 | +|---|---|---| +| `tender_approval` | 招标信息审批 | 该 tender 不立项、不推进 | +| `tender_file_upload` | 等待上传招标文件 | 项目全部角色任务 | +| `human_docs_request` | 人类应提供的文件 | 项目全部角色任务 | +| `bid_delivery_confirm` | 最终标书交付确认 | 项目收尾 | + +**门禁的守门位置:投标产线的角色任务唯一创建者是 `bid_flow` 对账器**(每 15 秒状态对账)。 +对账器在创建任务前就检查 `pipeline_human_tasks.project_id = <项目id> AND status='pending'`, +有阻塞任务直接不派 —— 门禁在**创建源头**,不在引擎派发层。 + +> ⚠️ 概念区分(重要):`tenant_id` 是引擎的通用租户键(宿主可提供为 org_id / user_id 等)。 +> **只有角色任务(pipeline_id='role_task')这个具体场景约定 `tenant_id = project_id`** +> (与开发产线的既有任务创建约定一致,`_create_next_task` 同款)。 +> 这是角色任务派发机制的约定,不是引擎通用规则 —— 引擎层 SQL 不得引用该等价关系, +> 引擎对投标产线零特判;投标的阻塞语义全部收敛在投标产线自己的代码(bid_flow)里。 + +### 3.5 硬规则 + +- 审批不通过 = 终态(archived),不得再进入立项;需重新评估须人工 `reopen_tender`。 +- 章节写作任务与章节一对一(`params.chapter_id`),禁止一个任务写多章(对照开发产线「单任务不要过大」)。 +- 评审/评分只由 `agent.bid_reviewer` / `agent.bid_scorer` 执行,写作角色不得自评(源头杜绝越权:capability 按角色缩小)。 +- 得分/改进意见必须落库(`bid_reviews`/`bid_scores`),不接受"文档里声称评审通过"。 +- 案例与业绩一致性:技术方案引用的案例必须能对应到 `bid_kb_docs(doc_type=case)` 真实合同,禁止虚构客户/金额(写进 bid-doc-spec 技能,评审项硬检查)。 + +--- + +## 四、数据表(bid_ 前缀,与 sd_/pipeline_ 表共库 pipeline) + +| 表 | 说明 | 关键字段 | +|---|---|---| +| `bid_mail_accounts` | 招标邮箱配置(凭据不写死代码) | protocol/host/port/username/password(AES)/folder/filter_keywords/last_uid/enabled | +| `bid_tenders` | 招标信息 | mail_uid/subject/received_at/raw_content/title/purchaser/tender_no/summary/bid_start_at/bid_deadline/project_period/budget_amount/deposit/qualification_req/status/project_id | +| `bid_members` | 项目参与人员 | project_id/user_id/member_role/duty | +| `bid_tender_files` | 招标文件及附件 | project_id/tender_id/file_name/file_path/file_type/extract_status | +| `bid_scoring_items` | 评分项 + 得分规则 | project_id/section/item_name/max_score/scoring_rule/is_veto/weight/source_ref | +| `bid_qualifications` | 所需资质 | project_id/qual_name/requirement/is_mandatory/kb_doc_id/match_status/owner | +| `bid_doc_requirements` | 投标文件要求 | project_id/req_type/chapter_no/chapter_title/requirement/page_limit | +| `bid_chapters` | 标书章节 | project_id/chapter_no/title/scoring_item_ids/status/version/content/file_path/review_score/review_comment/revise_count/assignee_role | +| `bid_reviews` | 评审记录(章节级/整书级) | project_id/scope/chapter_id/round/reviewer/score/max_score/passed/comments | +| `bid_scores` | 评分明细(按评分项) | project_id/review_id/scoring_item_id/score/max_score/reason/improvement/target_chapter_id | +| `bid_kb_docs` | 公司投标知识库 | org_id/doc_type/doc_name/tags/file_path/content_text/valid_until/status | +| `bid_documents` | 合成标书 | project_id/version/file_path/page_count/total_score/status | + +复用平台表:`sd_projects`(项目,pipeline_id=bidding_general)、`pipeline_tasks`(角色任务链)、 +`pipeline_human_tasks`(审批/人类任务)、`pipeline_deliverables`(交付件)、`pipeline_agent_questions`(问答冒泡)。 + +--- + +## 五、工具 + +### 5.1 主 agent(驾驶舱)工具 —— `PipelineAbility.tools` + +**招标情报**:`fetch_tenders` `list_tenders` `tender_detail` `summarize_tender` +`submit_tender_approval` `approve_tender` `reject_tender` `archive_tender` `reopen_tender` + +**立项与人员**:`create_bid_project` `assign_members` `list_members` `request_tender_file` `list_tender_files` + +**解析结果查看**:`list_scoring_items` `list_qualifications` `list_doc_requirements` + +**知识库**:`search_bid_kb` `list_kb_docs` `add_kb_doc` `request_human_docs` + +**章节与标书**:`create_chapter_outline` `list_chapters` `chapter_detail` `compose_bid` `list_bid_documents` + +**评审评分**:`list_reviews` `bid_score_report` + +**通用复用**(引擎级,来自 shared_ability):`create_task` `list_tasks` `task_detail` `approve_task` +`complete_task` `cancel_task` `reset_task_retry` `start_agents` `diagnose_project` +`list_deliverables` `view_deliverable` `list_questions` `answer_question` `escalate_question` +`list_projects` `start_project` `complete_project` `archive_project` `pause_project` `resume_project` +`list_role_models` `set_role_model` `submit_deliverable` `approve_deliverable` `reject_deliverable` + +### 5.2 角色 agent 能力工具(capability → TOOL_SCHEMAS) + +| capability | 工具 | 授予角色 | +|---|---|---| +| `bid_tender_capability` | `fetch_tenders` `summarize_tender` `set_tender_fields` `submit_tender_approval` `archive_tender` `list_tenders` | agent.tender_scout | +| `bid_analysis_capability` | `add_scoring_item` `add_qualification` `add_doc_requirement` `create_chapter_outline` `list_scoring_items` `list_doc_requirements` `mark_file_extracted` | agent.tender_analyst | +| `bid_kb_capability` | `search_bid_kb` `match_qualification` `mark_qualification_missing` `request_human_docs` `list_qualifications` | agent.bid_prep | +| `bid_write_capability` | `list_chapters` `chapter_detail` `write_chapter` `submit_chapter` `list_scoring_items` `search_bid_kb` | agent.bid_writer | +| `bid_review_capability` | `review_chapter` `reject_chapter` `approve_chapter` `list_chapters` `list_scoring_items` `list_doc_requirements` | agent.bid_reviewer | +| `bid_compose_capability` | `compose_bid` `list_chapters` `list_doc_requirements` | agent.bid_compositor | +| `bid_score_capability` | `score_bid` `add_score_item_result` `request_chapter_revise` `list_scoring_items` `list_bid_documents` | agent.bid_scorer | + +> capability 按角色职责缩小 —— 写作角色拿不到 `review_chapter`/`score_bid`,评审角色拿不到 `write_chapter`。 +> 越权在源头杜绝,不靠事后 RBAC 校验。 + +--- + +## 六、技能(skills_library/pipelines/bidding_general/) + +### common/(产线通用规范,状态机 + 门禁) +| 技能 | 内容 | +|---|---| +| `bid-workflow` | 产线总纲:阶段链、门禁、两级循环、回退语义、机制 vs LLM 分界 | +| `tender` | 招标信息状态机:new→summarized→pending_approval→approved/rejected→archived | +| `bid-chapter` | 章节状态机 + 评审循环 + 重做上限 + 一章一任务 | +| `bid-review` | 评审与评分规范:对照评分项/得分规则打分、改进意见→章节映射、否决项处理 | +| `bid-qualification` | 资质匹配规范:知识库检索 → matched/missing/human_required → 人类文件清单 | +| `bid-doc-spec` | 投标文件编写规范:章节骨架来自评分标准原文、操作层内容、案例与业绩一一对应、禁虚构 | +| `team-communication` | 问答冒泡链(复用开发产线同名规范) | + +### roles/(角色定义,frontmatter 声明 capability) +`agent.tender_scout` `agent.tender_analyst` `agent.bid_prep` `agent.bid_writer` +`agent.bid_reviewer` `agent.bid_compositor` `agent.bid_scorer` `agent.pm` `agent.qc` + +--- + +## 七、代码落位 + +| 内容 | 位置 | 理由 | +|---|---|---| +| 主 agent 能力包 `bid_ability.py` | pipeline-service | 与 sdlc_ability 同构;ability 注册表在 core,任何模块可注册 | +| 角色能力工具 `bid_*_capability.py` | pipeline-service | `exec_capability_tool` 只 import `pipeline_service.*` | +| 邮箱采集 `bid_mail_fetcher.py` | pipeline-service | 被能力工具调用 | +| 通用任务 handler 汇总 `shared_ability.py` | pipeline-service | 复用而非重写 SDLC 的通用任务/交付件/问答 handler | +| 表 / CRUD / UI / dspy / RBAC | pipeline-bidding(新模块仓库) | 产线业务资产独立仓库,宿主 load_pipeline_bidding() 装载 | +| 技能 | pipeline-core/skills_library/pipelines/bidding_general | 技能库统一目录,部署期同步到机构工作空间 | + +--- + +## 八、验收口径 + +1. `bid_*` 12 张表建表成功,CRUD 页面可增删改查。 +2. `fetch_tenders` 真连阿里云企业邮 IMAP 取到邮件并入库(凭据来自 `bid_mail_accounts`)。 +3. `summarize_tender` 真调 LLM 产出摘要与 5 要素并落库。 +4. 审批不通过 → tender 状态 archived;通过 → sd_projects 出现 pipeline_id=bidding_general 的项目。 +5. 上传招标文件人类任务 pending 时,poller 不派发该项目 agent 任务(项目级门禁生效)。 +6. 章节评审不达标 → 章节 rejected + 自动派生重写任务(DB 可查)。 +7. 整书评分不达标 → bid_scores 有 improvement + target_chapter_id,命中章节回 rejected。 +8. 全流程任务链在 `pipeline_tasks` 中可追溯(role 从 tender_scout 到 bid_scorer)。 diff --git a/i18n/en/msg.txt b/i18n/en/msg.txt new file mode 100644 index 0000000..c7bf072 --- /dev/null +++ b/i18n/en/msg.txt @@ -0,0 +1,30 @@ +招标信息: Tender Notices +招标信息台: Tender Inbox +邮箱配置: Mail Account Config +投标知识库: Bid Knowledge Base +评分项: Scoring Items +标书章节: Bid Chapters +合成标书: Assembled Bid Document +采集招标信息: Fetch Tender Notices +提交审批: Submit for Approval +审批通过: Approved +审批不通过: Rejected +存档作废: Archived / Voided +立项: Create Project +参与人员: Team Members +上传招标文件: Upload Tender Document +资质要求: Qualification Requirements +投标文件要求: Bid Document Requirements +章节评审: Chapter Review +整书评分: Whole-document Scoring +改进意见: Improvement Suggestions +得分规则: Scoring Rules +评审通过: Review Passed +退回重写: Returned for Rewrite +交付确认: Delivery Confirmation +投标开始时间: Bid Start Time +投标截止时间: Bid Deadline +项目周期: Project Duration +招标金额: Budget Amount +投标保证金: Bid Bond +内容摘要: Summary diff --git a/i18n/zh/msg.txt b/i18n/zh/msg.txt new file mode 100644 index 0000000..b59c2ce --- /dev/null +++ b/i18n/zh/msg.txt @@ -0,0 +1,30 @@ +招标信息: 招标信息 +招标信息台: 招标信息台 +邮箱配置: 邮箱配置 +投标知识库: 投标知识库 +评分项: 评分项 +标书章节: 标书章节 +合成标书: 合成标书 +采集招标信息: 采集招标信息 +提交审批: 提交审批 +审批通过: 审批通过 +审批不通过: 审批不通过 +存档作废: 存档作废 +立项: 立项 +参与人员: 参与人员 +上传招标文件: 上传招标文件 +资质要求: 资质要求 +投标文件要求: 投标文件要求 +章节评审: 章节评审 +整书评分: 整书评分 +改进意见: 改进意见 +得分规则: 得分规则 +评审通过: 评审通过 +退回重写: 退回重写 +交付确认: 交付确认 +投标开始时间: 投标开始时间 +投标截止时间: 投标截止时间 +项目周期: 项目周期 +招标金额: 招标金额 +投标保证金: 投标保证金 +内容摘要: 内容摘要 diff --git a/init/data.json b/init/data.json new file mode 100644 index 0000000..c8d2268 --- /dev/null +++ b/init/data.json @@ -0,0 +1,136 @@ +{ + "pipelines": [ + { + "id": "bidding_general", + "name": "通用投标产线", + "pipeline_type": "bidding", + "description": "招标信息采集→内容摘要→审批→立项与人员→招标文件解析(评分项/得分规则/资质/投标文件要求)→资质准备→分章节编写→章节评审→合成标书→整书评分→交付", + "status": "published", + "org_id": "0" + } + ], + "appcodes": [ + { + "parentid": "bid_tender_status", + "parentname": "招标信息状态", + "items": [ + {"k": "new", "v": "新采集"}, + {"k": "summarized", "v": "已摘要"}, + {"k": "pending_approval", "v": "待审批"}, + {"k": "approved", "v": "审批通过"}, + {"k": "rejected", "v": "审批不通过"}, + {"k": "archived", "v": "存档作废"} + ] + }, + { + "parentid": "bid_tender_source", + "parentname": "招标信息来源", + "items": [ + {"k": "mail", "v": "邮件采集"}, + {"k": "manual", "v": "手工录入"} + ] + }, + { + "parentid": "bid_member_role", + "parentname": "投标人员角色", + "items": [ + {"k": "leader", "v": "投标负责人"}, + {"k": "writer", "v": "标书编写"}, + {"k": "qual", "v": "资质资料"}, + {"k": "price", "v": "报价"}, + {"k": "reviewer", "v": "评审"} + ] + }, + { + "parentid": "bid_file_type", + "parentname": "招标文件类型", + "items": [ + {"k": "tender_doc", "v": "招标文件"}, + {"k": "qa", "v": "答疑补遗"}, + {"k": "attachment", "v": "附件"} + ] + }, + { + "parentid": "bid_scoring_section", + "parentname": "评分部分", + "items": [ + {"k": "business", "v": "商务"}, + {"k": "technical", "v": "技术"}, + {"k": "price", "v": "价格"} + ] + }, + { + "parentid": "bid_qual_match_status", + "parentname": "资质匹配状态", + "items": [ + {"k": "pending", "v": "待匹配"}, + {"k": "matched", "v": "已匹配知识库"}, + {"k": "missing", "v": "缺失"}, + {"k": "human_required", "v": "需人工提供"} + ] + }, + { + "parentid": "bid_qual_owner", + "parentname": "资质提供方", + "items": [ + {"k": "kb", "v": "公司知识库"}, + {"k": "human", "v": "人工提供"} + ] + }, + { + "parentid": "bid_req_type", + "parentname": "投标文件要求类型", + "items": [ + {"k": "structure", "v": "章节结构"}, + {"k": "format", "v": "格式要求"}, + {"k": "seal", "v": "密封要求"}, + {"k": "copies", "v": "份数要求"}, + {"k": "deadline", "v": "时间节点"}, + {"k": "other", "v": "其他"} + ] + }, + { + "parentid": "bid_chapter_status", + "parentname": "章节状态", + "items": [ + {"k": "pending", "v": "待编写"}, + {"k": "writing", "v": "编写中"}, + {"k": "written", "v": "已提交评审"}, + {"k": "reviewing", "v": "评审中"}, + {"k": "approved", "v": "评审通过"}, + {"k": "rejected", "v": "退回重写"} + ] + }, + { + "parentid": "bid_review_scope", + "parentname": "评审范围", + "items": [ + {"k": "chapter", "v": "章节评审"}, + {"k": "whole", "v": "整书评审"} + ] + }, + { + "parentid": "bid_kb_doc_type", + "parentname": "知识库文档类型", + "items": [ + {"k": "qualification", "v": "资质证书"}, + {"k": "cert", "v": "其他证照"}, + {"k": "case", "v": "业绩案例"}, + {"k": "template", "v": "模板"}, + {"k": "finance", "v": "财务资料"}, + {"k": "personnel", "v": "人员资料"} + ] + }, + { + "parentid": "bid_doc_status", + "parentname": "标书状态", + "items": [ + {"k": "draft", "v": "已合成待评分"}, + {"k": "reviewing", "v": "评分中"}, + {"k": "passed", "v": "评分通过"}, + {"k": "rejected", "v": "评分不通过"}, + {"k": "delivered", "v": "已交付"} + ] + } + ] +} diff --git a/json/bid_chapters.json b/json/bid_chapters.json new file mode 100644 index 0000000..a4ef90c --- /dev/null +++ b/json/bid_chapters.json @@ -0,0 +1,50 @@ +{ + "tblname": "bid_chapters", + "title": "投标文件章节", + "params": { + "sortby": [ + "order_no", + "chapter_no" + ], + "confidential_fields": [], + "browserfields": { + "exclouded": [ + "content", + "outline", + "scoring_item_ids" + ] + }, + "editexclouded": [ + "id", + "created_at", + "updated_at", + "task_id", + "version", + "word_count" + ], + "editable": { + "new_data_url": "{{entire_url('add_bid_chapters.dspy')}}", + "update_data_url": "{{entire_url('update_bid_chapters.dspy')}}", + "delete_data_url": "{{entire_url('delete_bid_chapters.dspy')}}" + }, + "data_filter": { + "AND": [ + { + "field": "project_id", + "op": "=", + "var": "project_id" + }, + { + "field": "status", + "op": "=", + "var": "status_input" + }, + { + "field": "title", + "op": "LIKE", + "var": "title_input" + } + ] + } + } +} diff --git a/json/bid_doc_requirements.json b/json/bid_doc_requirements.json new file mode 100644 index 0000000..f680c89 --- /dev/null +++ b/json/bid_doc_requirements.json @@ -0,0 +1,38 @@ +{ + "tblname": "bid_doc_requirements", + "title": "投标文件要求", + "params": { + "sortby": [ + "req_type", + "order_no" + ], + "confidential_fields": [], + "browserfields": { + "exclouded": [] + }, + "editexclouded": [ + "id", + "created_at", + "updated_at" + ], + "editable": { + "new_data_url": "{{entire_url('add_bid_doc_requirements.dspy')}}", + "update_data_url": "{{entire_url('update_bid_doc_requirements.dspy')}}", + "delete_data_url": "{{entire_url('delete_bid_doc_requirements.dspy')}}" + }, + "data_filter": { + "AND": [ + { + "field": "project_id", + "op": "=", + "var": "project_id" + }, + { + "field": "req_type", + "op": "=", + "var": "type_input" + } + ] + } + } +} diff --git a/json/bid_documents.json b/json/bid_documents.json new file mode 100644 index 0000000..f11fe7f --- /dev/null +++ b/json/bid_documents.json @@ -0,0 +1,39 @@ +{ + "tblname": "bid_documents", + "title": "合成标书", + "params": { + "sortby": [ + "version desc" + ], + "confidential_fields": [], + "browserfields": { + "exclouded": [] + }, + "editexclouded": [ + "id", + "created_at", + "updated_at", + "task_id", + "review_id" + ], + "editable": { + "new_data_url": "{{entire_url('add_bid_documents.dspy')}}", + "update_data_url": "{{entire_url('update_bid_documents.dspy')}}", + "delete_data_url": "{{entire_url('delete_bid_documents.dspy')}}" + }, + "data_filter": { + "AND": [ + { + "field": "project_id", + "op": "=", + "var": "project_id" + }, + { + "field": "status", + "op": "=", + "var": "status_input" + } + ] + } + } +} diff --git a/json/bid_kb_docs.json b/json/bid_kb_docs.json new file mode 100644 index 0000000..229d547 --- /dev/null +++ b/json/bid_kb_docs.json @@ -0,0 +1,45 @@ +{ + "tblname": "bid_kb_docs", + "title": "投标知识库", + "params": { + "sortby": [ + "doc_type", + "created_at desc" + ], + "confidential_fields": [], + "browserfields": { + "exclouded": [ + "content_text" + ] + }, + "editexclouded": [ + "id", + "created_at", + "updated_at" + ], + "editable": { + "new_data_url": "{{entire_url('add_bid_kb_docs.dspy')}}", + "update_data_url": "{{entire_url('update_bid_kb_docs.dspy')}}", + "delete_data_url": "{{entire_url('delete_bid_kb_docs.dspy')}}" + }, + "data_filter": { + "AND": [ + { + "field": "doc_name", + "op": "LIKE", + "var": "name_input" + }, + { + "field": "doc_type", + "op": "=", + "var": "type_input" + }, + { + "field": "status", + "op": "=", + "var": "status_input" + } + ] + } + } +} diff --git a/json/bid_mail_accounts.json b/json/bid_mail_accounts.json new file mode 100644 index 0000000..77e0595 --- /dev/null +++ b/json/bid_mail_accounts.json @@ -0,0 +1,42 @@ +{ + "tblname": "bid_mail_accounts", + "title": "招标邮箱配置", + "params": { + "sortby": [ + "created_at desc" + ], + "confidential_fields": [], + "browserfields": { + "exclouded": [ + "password", + "last_uid" + ] + }, + "editexclouded": [ + "id", + "created_at", + "updated_at", + "last_uid", + "last_fetch_at" + ], + "editable": { + "new_data_url": "{{entire_url('add_bid_mail_accounts.dspy')}}", + "update_data_url": "{{entire_url('update_bid_mail_accounts.dspy')}}", + "delete_data_url": "{{entire_url('delete_bid_mail_accounts.dspy')}}" + }, + "data_filter": { + "AND": [ + { + "field": "account_name", + "op": "LIKE", + "var": "name_input" + }, + { + "field": "enabled", + "op": "=", + "var": "enabled_input" + } + ] + } + } +} diff --git a/json/bid_members.json b/json/bid_members.json new file mode 100644 index 0000000..3019683 --- /dev/null +++ b/json/bid_members.json @@ -0,0 +1,37 @@ +{ + "tblname": "bid_members", + "title": "投标参与人员", + "params": { + "sortby": [ + "created_at" + ], + "confidential_fields": [], + "browserfields": { + "exclouded": [] + }, + "editexclouded": [ + "id", + "created_at", + "updated_at" + ], + "editable": { + "new_data_url": "{{entire_url('add_bid_members.dspy')}}", + "update_data_url": "{{entire_url('update_bid_members.dspy')}}", + "delete_data_url": "{{entire_url('delete_bid_members.dspy')}}" + }, + "data_filter": { + "AND": [ + { + "field": "project_id", + "op": "=", + "var": "project_id" + }, + { + "field": "user_name", + "op": "LIKE", + "var": "name_input" + } + ] + } + } +} diff --git a/json/bid_qualifications.json b/json/bid_qualifications.json new file mode 100644 index 0000000..9e45295 --- /dev/null +++ b/json/bid_qualifications.json @@ -0,0 +1,38 @@ +{ + "tblname": "bid_qualifications", + "title": "招标所需资质", + "params": { + "sortby": [ + "created_at" + ], + "confidential_fields": [], + "browserfields": { + "exclouded": [] + }, + "editexclouded": [ + "id", + "created_at", + "updated_at", + "human_task_id" + ], + "editable": { + "new_data_url": "{{entire_url('add_bid_qualifications.dspy')}}", + "update_data_url": "{{entire_url('update_bid_qualifications.dspy')}}", + "delete_data_url": "{{entire_url('delete_bid_qualifications.dspy')}}" + }, + "data_filter": { + "AND": [ + { + "field": "project_id", + "op": "=", + "var": "project_id" + }, + { + "field": "match_status", + "op": "=", + "var": "status_input" + } + ] + } + } +} diff --git a/json/bid_reviews.json b/json/bid_reviews.json new file mode 100644 index 0000000..85d720c --- /dev/null +++ b/json/bid_reviews.json @@ -0,0 +1,48 @@ +{ + "tblname": "bid_reviews", + "title": "标书评审记录", + "params": { + "sortby": [ + "created_at desc" + ], + "confidential_fields": [], + "browserfields": { + "exclouded": [ + "detail" + ] + }, + "editexclouded": [ + "id", + "created_at", + "updated_at", + "task_id" + ], + "editable": { + "new_data_url": "{{entire_url('add_bid_reviews.dspy')}}", + "update_data_url": "{{entire_url('update_bid_reviews.dspy')}}", + "delete_data_url": "{{entire_url('delete_bid_reviews.dspy')}}" + }, + "data_filter": { + "AND": [ + { + "field": "project_id", + "op": "=", + "var": "project_id" + }, + { + "field": "scope", + "op": "=", + "var": "scope_input" + } + ] + }, + "subtables": [ + { + "field": "review_id", + "title": "评分明细", + "url": "{{entire_url('../bid_scores?review_id=${id}')}}", + "subtable": "bid_scores" + } + ] + } +} diff --git a/json/bid_scores.json b/json/bid_scores.json new file mode 100644 index 0000000..8a8ca2b --- /dev/null +++ b/json/bid_scores.json @@ -0,0 +1,37 @@ +{ + "tblname": "bid_scores", + "title": "标书评分明细", + "params": { + "sortby": [ + "created_at" + ], + "confidential_fields": [], + "browserfields": { + "exclouded": [] + }, + "editexclouded": [ + "id", + "created_at", + "updated_at" + ], + "editable": { + "new_data_url": "{{entire_url('add_bid_scores.dspy')}}", + "update_data_url": "{{entire_url('update_bid_scores.dspy')}}", + "delete_data_url": "{{entire_url('delete_bid_scores.dspy')}}" + }, + "data_filter": { + "AND": [ + { + "field": "review_id", + "op": "=", + "var": "review_id" + }, + { + "field": "project_id", + "op": "=", + "var": "project_id" + } + ] + } + } +} diff --git a/json/bid_scoring_items.json b/json/bid_scoring_items.json new file mode 100644 index 0000000..b5611a9 --- /dev/null +++ b/json/bid_scoring_items.json @@ -0,0 +1,43 @@ +{ + "tblname": "bid_scoring_items", + "title": "评分项与得分规则", + "params": { + "sortby": [ + "section", + "order_no" + ], + "confidential_fields": [], + "browserfields": { + "exclouded": [] + }, + "editexclouded": [ + "id", + "created_at", + "updated_at" + ], + "editable": { + "new_data_url": "{{entire_url('add_bid_scoring_items.dspy')}}", + "update_data_url": "{{entire_url('update_bid_scoring_items.dspy')}}", + "delete_data_url": "{{entire_url('delete_bid_scoring_items.dspy')}}" + }, + "data_filter": { + "AND": [ + { + "field": "project_id", + "op": "=", + "var": "project_id" + }, + { + "field": "section", + "op": "=", + "var": "section_input" + }, + { + "field": "item_name", + "op": "LIKE", + "var": "name_input" + } + ] + } + } +} diff --git a/json/bid_tender_files.json b/json/bid_tender_files.json new file mode 100644 index 0000000..305369d --- /dev/null +++ b/json/bid_tender_files.json @@ -0,0 +1,40 @@ +{ + "tblname": "bid_tender_files", + "title": "招标文件", + "params": { + "sortby": [ + "created_at desc" + ], + "confidential_fields": [], + "browserfields": { + "exclouded": [ + "content_text" + ] + }, + "editexclouded": [ + "id", + "created_at", + "updated_at", + "content_text" + ], + "editable": { + "new_data_url": "{{entire_url('add_bid_tender_files.dspy')}}", + "update_data_url": "{{entire_url('update_bid_tender_files.dspy')}}", + "delete_data_url": "{{entire_url('delete_bid_tender_files.dspy')}}" + }, + "data_filter": { + "AND": [ + { + "field": "project_id", + "op": "=", + "var": "project_id" + }, + { + "field": "extract_status", + "op": "=", + "var": "status_input" + } + ] + } + } +} diff --git a/json/bid_tenders.json b/json/bid_tenders.json new file mode 100644 index 0000000..88bc454 --- /dev/null +++ b/json/bid_tenders.json @@ -0,0 +1,52 @@ +{ + "tblname": "bid_tenders", + "title": "招标信息", + "params": { + "sortby": [ + "received_at desc" + ], + "confidential_fields": [], + "browserfields": { + "exclouded": [ + "raw_content", + "attach_json", + "key_dates", + "org_id", + "account_id", + "mail_uid", + "risk_note" + ] + }, + "editexclouded": [ + "id", + "created_at", + "updated_at", + "approval_task_id", + "project_id" + ], + "editable": { + "new_data_url": "{{entire_url('add_bid_tenders.dspy')}}", + "update_data_url": "{{entire_url('update_bid_tenders.dspy')}}", + "delete_data_url": "{{entire_url('delete_bid_tenders.dspy')}}" + }, + "data_filter": { + "AND": [ + { + "field": "title", + "op": "LIKE", + "var": "title_input" + }, + { + "field": "status", + "op": "=", + "var": "status_input" + }, + { + "field": "purchaser", + "op": "LIKE", + "var": "purchaser_input" + } + ] + } + } +} diff --git a/json/build.sh b/json/build.sh new file mode 100644 index 0000000..c9f5f7e --- /dev/null +++ b/json/build.sh @@ -0,0 +1,4 @@ +#!/bin/bash +# 由 models/*.json + json/*.json 生成 wwwroot 下 CRUD 页面 +cd "$(dirname "$0")" +../../pipeline-app/py3/bin/xls2ui -m ../models -o ../wwwroot pipeline_bidding *.json diff --git a/models/bid_chapters.json b/models/bid_chapters.json new file mode 100644 index 0000000..4174ebd --- /dev/null +++ b/models/bid_chapters.json @@ -0,0 +1,193 @@ +{ + "summary": [ + { + "name": "bid_chapters", + "title": "投标文件章节", + "primary": [ + "id" + ], + "catelog": "entity" + } + ], + "fields": [ + { + "name": "id", + "title": "主键ID", + "type": "str", + "length": 32, + "nullable": "no" + }, + { + "name": "project_id", + "title": "项目ID", + "type": "str", + "length": 32, + "nullable": "no" + }, + { + "name": "chapter_no", + "title": "章节号", + "type": "str", + "length": 32, + "nullable": "no" + }, + { + "name": "parent_no", + "title": "父章节号", + "type": "str", + "length": 32 + }, + { + "name": "title", + "title": "章节标题", + "type": "str", + "length": 255, + "nullable": "no" + }, + { + "name": "section", + "title": "所属部分", + "type": "str", + "length": 32, + "default": "technical" + }, + { + "name": "scoring_item_ids", + "title": "对应评分项(JSON)", + "type": "text" + }, + { + "name": "outline", + "title": "章节要点", + "type": "text" + }, + { + "name": "status", + "title": "章节状态", + "type": "str", + "length": 16, + "nullable": "no", + "default": "pending" + }, + { + "name": "version", + "title": "版本号", + "type": "int", + "nullable": "no", + "default": "1" + }, + { + "name": "content", + "title": "章节正文", + "type": "text" + }, + { + "name": "file_path", + "title": "章节文件路径", + "type": "str", + "length": 500 + }, + { + "name": "word_count", + "title": "字数", + "type": "int", + "default": "0" + }, + { + "name": "review_score", + "title": "评审得分", + "type": "float", + "length": 8, + "dec": 2 + }, + { + "name": "max_score", + "title": "对应满分", + "type": "float", + "length": 8, + "dec": 2 + }, + { + "name": "review_comment", + "title": "评审意见/改进意见", + "type": "text" + }, + { + "name": "revise_count", + "title": "重做次数", + "type": "int", + "nullable": "no", + "default": "0" + }, + { + "name": "assignee_role", + "title": "承办角色", + "type": "str", + "length": 64, + "default": "agent.bid_writer" + }, + { + "name": "task_id", + "title": "关联任务ID", + "type": "str", + "length": 32 + }, + { + "name": "order_no", + "title": "排序", + "type": "int", + "default": "0" + }, + { + "name": "created_at", + "title": "创建时间", + "type": "timestamp", + "nullable": "no" + }, + { + "name": "updated_at", + "title": "更新时间", + "type": "timestamp" + } + ], + "indexes": [ + { + "name": "idx_bc_project", + "idxtype": "index", + "idxfields": [ + "project_id" + ] + }, + { + "name": "idx_bc_status", + "idxtype": "index", + "idxfields": [ + "status" + ] + }, + { + "name": "idx_bc_no", + "idxtype": "index", + "idxfields": [ + "project_id", + "chapter_no" + ] + } + ], + "codes": [ + { + "field": "status", + "table": "appcodes_kv", + "valuefield": "k", + "textfield": "v", + "cond": "parentid='bid_chapter_status'" + }, + { + "field": "section", + "table": "appcodes_kv", + "valuefield": "k", + "textfield": "v", + "cond": "parentid='bid_scoring_section'" + } + ] +} diff --git a/models/bid_doc_requirements.json b/models/bid_doc_requirements.json new file mode 100644 index 0000000..009dfda --- /dev/null +++ b/models/bid_doc_requirements.json @@ -0,0 +1,106 @@ +{ + "summary": [ + { + "name": "bid_doc_requirements", + "title": "投标文件要求", + "primary": [ + "id" + ], + "catelog": "entity" + } + ], + "fields": [ + { + "name": "id", + "title": "主键ID", + "type": "str", + "length": 32, + "nullable": "no" + }, + { + "name": "project_id", + "title": "项目ID", + "type": "str", + "length": 32, + "nullable": "no" + }, + { + "name": "req_type", + "title": "要求类型", + "type": "str", + "length": 32, + "nullable": "no", + "default": "structure" + }, + { + "name": "chapter_no", + "title": "章节号", + "type": "str", + "length": 32 + }, + { + "name": "chapter_title", + "title": "章节标题", + "type": "str", + "length": 255 + }, + { + "name": "requirement", + "title": "要求内容", + "type": "text" + }, + { + "name": "page_limit", + "title": "页数限制", + "type": "int" + }, + { + "name": "source_ref", + "title": "招标文件出处", + "type": "str", + "length": 500 + }, + { + "name": "order_no", + "title": "排序", + "type": "int", + "default": "0" + }, + { + "name": "created_at", + "title": "创建时间", + "type": "timestamp", + "nullable": "no" + }, + { + "name": "updated_at", + "title": "更新时间", + "type": "timestamp" + } + ], + "indexes": [ + { + "name": "idx_bdr_project", + "idxtype": "index", + "idxfields": [ + "project_id" + ] + }, + { + "name": "idx_bdr_type", + "idxtype": "index", + "idxfields": [ + "req_type" + ] + } + ], + "codes": [ + { + "field": "req_type", + "table": "appcodes_kv", + "valuefield": "k", + "textfield": "v", + "cond": "parentid='bid_req_type'" + } + ] +} diff --git a/models/bid_documents.json b/models/bid_documents.json new file mode 100644 index 0000000..35ff3b9 --- /dev/null +++ b/models/bid_documents.json @@ -0,0 +1,142 @@ +{ + "summary": [ + { + "name": "bid_documents", + "title": "合成标书", + "primary": [ + "id" + ], + "catelog": "entity" + } + ], + "fields": [ + { + "name": "id", + "title": "主键ID", + "type": "str", + "length": 32, + "nullable": "no" + }, + { + "name": "project_id", + "title": "项目ID", + "type": "str", + "length": 32, + "nullable": "no" + }, + { + "name": "version", + "title": "版本号", + "type": "int", + "nullable": "no", + "default": "1" + }, + { + "name": "doc_name", + "title": "标书名称", + "type": "str", + "length": 255 + }, + { + "name": "file_path", + "title": "文件路径", + "type": "str", + "length": 500 + }, + { + "name": "page_count", + "title": "页数", + "type": "int", + "default": "0" + }, + { + "name": "chapter_count", + "title": "章节数", + "type": "int", + "default": "0" + }, + { + "name": "total_score", + "title": "总得分", + "type": "float", + "length": 8, + "dec": 2 + }, + { + "name": "max_score", + "title": "总满分", + "type": "float", + "length": 8, + "dec": 2 + }, + { + "name": "pass_ratio", + "title": "通过门限", + "type": "float", + "length": 5, + "dec": 4 + }, + { + "name": "status", + "title": "状态", + "type": "str", + "length": 16, + "nullable": "no", + "default": "draft" + }, + { + "name": "review_id", + "title": "评审记录ID", + "type": "str", + "length": 32 + }, + { + "name": "task_id", + "title": "关联任务ID", + "type": "str", + "length": 32 + }, + { + "name": "created_by", + "title": "创建人", + "type": "str", + "length": 32 + }, + { + "name": "created_at", + "title": "创建时间", + "type": "timestamp", + "nullable": "no" + }, + { + "name": "updated_at", + "title": "更新时间", + "type": "timestamp" + } + ], + "indexes": [ + { + "name": "idx_bdoc_project", + "idxtype": "index", + "idxfields": [ + "project_id" + ] + }, + { + "name": "idx_bdoc_status", + "idxtype": "index", + "idxfields": [ + "status" + ] + } + ], + "codes": [ + { + "field": "status", + "table": "appcodes_kv", + "valuefield": "k", + "textfield": "v", + "cond": "parentid='bid_doc_status'" + } + ] +} diff --git a/models/bid_kb_docs.json b/models/bid_kb_docs.json new file mode 100644 index 0000000..91b7138 --- /dev/null +++ b/models/bid_kb_docs.json @@ -0,0 +1,152 @@ +{ + "summary": [ + { + "name": "bid_kb_docs", + "title": "公司投标知识库", + "primary": [ + "id" + ], + "catelog": "entity" + } + ], + "fields": [ + { + "name": "id", + "title": "主键ID", + "type": "str", + "length": 32, + "nullable": "no" + }, + { + "name": "org_id", + "title": "机构ID", + "type": "str", + "length": 32, + "nullable": "no", + "default": "0" + }, + { + "name": "doc_type", + "title": "文档类型", + "type": "str", + "length": 32, + "nullable": "no", + "default": "qualification" + }, + { + "name": "doc_name", + "title": "文档名称", + "type": "str", + "length": 255, + "nullable": "no" + }, + { + "name": "doc_no", + "title": "证书/合同编号", + "type": "str", + "length": 64 + }, + { + "name": "tags", + "title": "标签(逗号分隔)", + "type": "str", + "length": 500 + }, + { + "name": "file_path", + "title": "文件路径", + "type": "str", + "length": 500 + }, + { + "name": "content_text", + "title": "正文内容", + "type": "text" + }, + { + "name": "issuer", + "title": "颁发/签约方", + "type": "str", + "length": 255 + }, + { + "name": "customer", + "title": "案例客户", + "type": "str", + "length": 255 + }, + { + "name": "contract_amount", + "title": "合同金额", + "type": "float", + "length": 18, + "dec": 2 + }, + { + "name": "valid_from", + "title": "生效日期", + "type": "date" + }, + { + "name": "valid_until", + "title": "有效期至", + "type": "date" + }, + { + "name": "status", + "title": "状态", + "type": "str", + "length": 16, + "default": "active" + }, + { + "name": "created_by", + "title": "创建人", + "type": "str", + "length": 32 + }, + { + "name": "created_at", + "title": "创建时间", + "type": "timestamp", + "nullable": "no" + }, + { + "name": "updated_at", + "title": "更新时间", + "type": "timestamp" + } + ], + "indexes": [ + { + "name": "idx_bkd_org", + "idxtype": "index", + "idxfields": [ + "org_id" + ] + }, + { + "name": "idx_bkd_type", + "idxtype": "index", + "idxfields": [ + "doc_type" + ] + }, + { + "name": "idx_bkd_status", + "idxtype": "index", + "idxfields": [ + "status" + ] + } + ], + "codes": [ + { + "field": "doc_type", + "table": "appcodes_kv", + "valuefield": "k", + "textfield": "v", + "cond": "parentid='bid_kb_doc_type'" + } + ] +} diff --git a/models/bid_mail_accounts.json b/models/bid_mail_accounts.json new file mode 100644 index 0000000..7a59fa8 --- /dev/null +++ b/models/bid_mail_accounts.json @@ -0,0 +1,159 @@ +{ + "summary": [ + { + "name": "bid_mail_accounts", + "title": "招标邮箱配置", + "primary": [ + "id" + ], + "catelog": "entity" + } + ], + "fields": [ + { + "name": "id", + "title": "主键ID", + "type": "str", + "length": 32, + "nullable": "no" + }, + { + "name": "org_id", + "title": "机构ID", + "type": "str", + "length": 32, + "nullable": "no", + "default": "0" + }, + { + "name": "account_name", + "title": "配置名称", + "type": "str", + "length": 128, + "nullable": "no" + }, + { + "name": "protocol", + "title": "协议", + "type": "str", + "length": 16, + "nullable": "no", + "default": "imap" + }, + { + "name": "host", + "title": "服务器地址", + "type": "str", + "length": 128, + "nullable": "no" + }, + { + "name": "port", + "title": "端口", + "type": "int", + "nullable": "no", + "default": "993" + }, + { + "name": "use_ssl", + "title": "使用SSL", + "type": "str", + "length": 1, + "nullable": "no", + "default": "1" + }, + { + "name": "username", + "title": "登录账号", + "type": "str", + "length": 128, + "nullable": "no" + }, + { + "name": "password", + "title": "登录密码(AES加密)", + "type": "str", + "length": 500, + "nullable": "no" + }, + { + "name": "folder", + "title": "邮件目录", + "type": "str", + "length": 64, + "default": "INBOX" + }, + { + "name": "filter_keywords", + "title": "招标关键词(逗号分隔)", + "type": "str", + "length": 500 + }, + { + "name": "fetch_days", + "title": "回溯天数", + "type": "int", + "default": "7" + }, + { + "name": "last_uid", + "title": "上次同步UID", + "type": "str", + "length": 64 + }, + { + "name": "last_fetch_at", + "title": "上次同步时间", + "type": "timestamp" + }, + { + "name": "enabled", + "title": "启用", + "type": "str", + "length": 1, + "nullable": "no", + "default": "1" + }, + { + "name": "status", + "title": "状态", + "type": "str", + "length": 16, + "default": "active" + }, + { + "name": "created_by", + "title": "创建人", + "type": "str", + "length": 32 + }, + { + "name": "created_at", + "title": "创建时间", + "type": "timestamp", + "nullable": "no" + }, + { + "name": "updated_at", + "title": "更新时间", + "type": "timestamp" + } + ], + "indexes": [ + { + "name": "idx_bma_org", + "idxtype": "index", + "idxfields": [ + "org_id" + ] + }, + { + "name": "idx_bma_enabled", + "idxtype": "index", + "idxfields": [ + "enabled" + ] + } + ], + "codes": [] +} diff --git a/models/bid_members.json b/models/bid_members.json new file mode 100644 index 0000000..e87584a --- /dev/null +++ b/models/bid_members.json @@ -0,0 +1,103 @@ +{ + "summary": [ + { + "name": "bid_members", + "title": "投标项目参与人员", + "primary": [ + "id" + ], + "catelog": "entity" + } + ], + "fields": [ + { + "name": "id", + "title": "主键ID", + "type": "str", + "length": 32, + "nullable": "no" + }, + { + "name": "project_id", + "title": "项目ID", + "type": "str", + "length": 32, + "nullable": "no" + }, + { + "name": "user_id", + "title": "用户ID", + "type": "str", + "length": 32 + }, + { + "name": "user_name", + "title": "姓名", + "type": "str", + "length": 128 + }, + { + "name": "member_role", + "title": "承担角色", + "type": "str", + "length": 64, + "nullable": "no", + "default": "writer" + }, + { + "name": "duty", + "title": "职责说明", + "type": "str", + "length": 500 + }, + { + "name": "status", + "title": "状态", + "type": "str", + "length": 16, + "default": "active" + }, + { + "name": "created_by", + "title": "创建人", + "type": "str", + "length": 32 + }, + { + "name": "created_at", + "title": "创建时间", + "type": "timestamp", + "nullable": "no" + }, + { + "name": "updated_at", + "title": "更新时间", + "type": "timestamp" + } + ], + "indexes": [ + { + "name": "idx_bm_project", + "idxtype": "index", + "idxfields": [ + "project_id" + ] + }, + { + "name": "idx_bm_user", + "idxtype": "index", + "idxfields": [ + "user_id" + ] + } + ], + "codes": [ + { + "field": "member_role", + "table": "appcodes_kv", + "valuefield": "k", + "textfield": "v", + "cond": "parentid='bid_member_role'" + } + ] +} diff --git a/models/bid_qualifications.json b/models/bid_qualifications.json new file mode 100644 index 0000000..1b37d82 --- /dev/null +++ b/models/bid_qualifications.json @@ -0,0 +1,131 @@ +{ + "summary": [ + { + "name": "bid_qualifications", + "title": "招标所需资质", + "primary": [ + "id" + ], + "catelog": "entity" + } + ], + "fields": [ + { + "name": "id", + "title": "主键ID", + "type": "str", + "length": 32, + "nullable": "no" + }, + { + "name": "project_id", + "title": "项目ID", + "type": "str", + "length": 32, + "nullable": "no" + }, + { + "name": "qual_name", + "title": "资质名称", + "type": "str", + "length": 255, + "nullable": "no" + }, + { + "name": "requirement", + "title": "资质要求原文", + "type": "text" + }, + { + "name": "is_mandatory", + "title": "是否必需", + "type": "str", + "length": 1, + "nullable": "no", + "default": "1" + }, + { + "name": "owner", + "title": "提供方", + "type": "str", + "length": 16, + "nullable": "no", + "default": "kb" + }, + { + "name": "kb_doc_id", + "title": "知识库文档ID", + "type": "str", + "length": 32 + }, + { + "name": "match_status", + "title": "匹配状态", + "type": "str", + "length": 16, + "nullable": "no", + "default": "pending" + }, + { + "name": "file_path", + "title": "文档路径", + "type": "str", + "length": 500 + }, + { + "name": "note", + "title": "说明", + "type": "str", + "length": 500 + }, + { + "name": "human_task_id", + "title": "人类任务ID", + "type": "str", + "length": 32 + }, + { + "name": "created_at", + "title": "创建时间", + "type": "timestamp", + "nullable": "no" + }, + { + "name": "updated_at", + "title": "更新时间", + "type": "timestamp" + } + ], + "indexes": [ + { + "name": "idx_bq_project", + "idxtype": "index", + "idxfields": [ + "project_id" + ] + }, + { + "name": "idx_bq_match", + "idxtype": "index", + "idxfields": [ + "match_status" + ] + } + ], + "codes": [ + { + "field": "match_status", + "table": "appcodes_kv", + "valuefield": "k", + "textfield": "v", + "cond": "parentid='bid_qual_match_status'" + }, + { + "field": "owner", + "table": "appcodes_kv", + "valuefield": "k", + "textfield": "v", + "cond": "parentid='bid_qual_owner'" + } + ] +} diff --git a/models/bid_reviews.json b/models/bid_reviews.json new file mode 100644 index 0000000..9126235 --- /dev/null +++ b/models/bid_reviews.json @@ -0,0 +1,156 @@ +{ + "summary": [ + { + "name": "bid_reviews", + "title": "标书评审记录", + "primary": [ + "id" + ], + "catelog": "entity" + } + ], + "fields": [ + { + "name": "id", + "title": "主键ID", + "type": "str", + "length": 32, + "nullable": "no" + }, + { + "name": "project_id", + "title": "项目ID", + "type": "str", + "length": 32, + "nullable": "no" + }, + { + "name": "scope", + "title": "评审范围", + "type": "str", + "length": 16, + "nullable": "no", + "default": "chapter" + }, + { + "name": "chapter_id", + "title": "章节ID", + "type": "str", + "length": 32 + }, + { + "name": "document_id", + "title": "标书ID", + "type": "str", + "length": 32 + }, + { + "name": "round", + "title": "评审轮次", + "type": "int", + "nullable": "no", + "default": "1" + }, + { + "name": "reviewer", + "title": "评审方", + "type": "str", + "length": 64 + }, + { + "name": "review_type", + "title": "评审类型", + "type": "str", + "length": 16, + "default": "agent" + }, + { + "name": "score", + "title": "得分", + "type": "float", + "length": 8, + "dec": 2 + }, + { + "name": "max_score", + "title": "满分", + "type": "float", + "length": 8, + "dec": 2 + }, + { + "name": "pass_ratio", + "title": "通过门限", + "type": "float", + "length": 5, + "dec": 4 + }, + { + "name": "passed", + "title": "是否通过", + "type": "str", + "length": 1, + "nullable": "no", + "default": "0" + }, + { + "name": "comments", + "title": "改进意见", + "type": "text" + }, + { + "name": "detail", + "title": "评审明细(JSON)", + "type": "text" + }, + { + "name": "task_id", + "title": "关联任务ID", + "type": "str", + "length": 32 + }, + { + "name": "created_at", + "title": "创建时间", + "type": "timestamp", + "nullable": "no" + }, + { + "name": "updated_at", + "title": "更新时间", + "type": "timestamp" + } + ], + "indexes": [ + { + "name": "idx_br_project", + "idxtype": "index", + "idxfields": [ + "project_id" + ] + }, + { + "name": "idx_br_scope", + "idxtype": "index", + "idxfields": [ + "scope" + ] + }, + { + "name": "idx_br_chapter", + "idxtype": "index", + "idxfields": [ + "chapter_id" + ] + } + ], + "codes": [ + { + "field": "scope", + "table": "appcodes_kv", + "valuefield": "k", + "textfield": "v", + "cond": "parentid='bid_review_scope'" + } + ] +} diff --git a/models/bid_scores.json b/models/bid_scores.json new file mode 100644 index 0000000..f2ab6aa --- /dev/null +++ b/models/bid_scores.json @@ -0,0 +1,111 @@ +{ + "summary": [ + { + "name": "bid_scores", + "title": "标书评分明细", + "primary": [ + "id" + ], + "catelog": "entity" + } + ], + "fields": [ + { + "name": "id", + "title": "主键ID", + "type": "str", + "length": 32, + "nullable": "no" + }, + { + "name": "project_id", + "title": "项目ID", + "type": "str", + "length": 32, + "nullable": "no" + }, + { + "name": "review_id", + "title": "评审记录ID", + "type": "str", + "length": 32 + }, + { + "name": "scoring_item_id", + "title": "评分项ID", + "type": "str", + "length": 32 + }, + { + "name": "item_name", + "title": "评分项名称", + "type": "str", + "length": 255 + }, + { + "name": "score", + "title": "得分", + "type": "float", + "length": 8, + "dec": 2 + }, + { + "name": "max_score", + "title": "满分", + "type": "float", + "length": 8, + "dec": 2 + }, + { + "name": "reason", + "title": "评分理由", + "type": "text" + }, + { + "name": "improvement", + "title": "改进意见", + "type": "text" + }, + { + "name": "target_chapter_id", + "title": "待改章节ID", + "type": "str", + "length": 32 + }, + { + "name": "created_at", + "title": "创建时间", + "type": "timestamp", + "nullable": "no" + }, + { + "name": "updated_at", + "title": "更新时间", + "type": "timestamp" + } + ], + "indexes": [ + { + "name": "idx_bs_project", + "idxtype": "index", + "idxfields": [ + "project_id" + ] + }, + { + "name": "idx_bs_review", + "idxtype": "index", + "idxfields": [ + "review_id" + ] + }, + { + "name": "idx_bs_chapter", + "idxtype": "index", + "idxfields": [ + "target_chapter_id" + ] + } + ], + "codes": [] +} diff --git a/models/bid_scoring_items.json b/models/bid_scoring_items.json new file mode 100644 index 0000000..adb9fec --- /dev/null +++ b/models/bid_scoring_items.json @@ -0,0 +1,132 @@ +{ + "summary": [ + { + "name": "bid_scoring_items", + "title": "评分项与得分规则", + "primary": [ + "id" + ], + "catelog": "entity" + } + ], + "fields": [ + { + "name": "id", + "title": "主键ID", + "type": "str", + "length": 32, + "nullable": "no" + }, + { + "name": "project_id", + "title": "项目ID", + "type": "str", + "length": 32, + "nullable": "no" + }, + { + "name": "section", + "title": "评分部分", + "type": "str", + "length": 32, + "nullable": "no", + "default": "technical" + }, + { + "name": "item_no", + "title": "评分项编号", + "type": "str", + "length": 32 + }, + { + "name": "item_name", + "title": "评分项名称", + "type": "str", + "length": 255, + "nullable": "no" + }, + { + "name": "max_score", + "title": "满分", + "type": "float", + "length": 8, + "dec": 2, + "nullable": "no", + "default": "0" + }, + { + "name": "scoring_rule", + "title": "得分规则", + "type": "text" + }, + { + "name": "is_veto", + "title": "否决项", + "type": "str", + "length": 1, + "nullable": "no", + "default": "0" + }, + { + "name": "weight", + "title": "权重", + "type": "float", + "length": 8, + "dec": 4 + }, + { + "name": "source_ref", + "title": "招标文件出处", + "type": "str", + "length": 500 + }, + { + "name": "target_chapter_no", + "title": "对应章节号", + "type": "str", + "length": 32 + }, + { + "name": "order_no", + "title": "排序", + "type": "int", + "default": "0" + }, + { + "name": "created_at", + "title": "创建时间", + "type": "timestamp", + "nullable": "no" + }, + { + "name": "updated_at", + "title": "更新时间", + "type": "timestamp" + } + ], + "indexes": [ + { + "name": "idx_bsi_project", + "idxtype": "index", + "idxfields": [ + "project_id" + ] + }, + { + "name": "idx_bsi_section", + "idxtype": "index", + "idxfields": [ + "section" + ] + } + ], + "codes": [ + { + "field": "section", + "table": "appcodes_kv", + "valuefield": "k", + "textfield": "v", + "cond": "parentid='bid_scoring_section'" + } + ] +} diff --git a/models/bid_tender_files.json b/models/bid_tender_files.json new file mode 100644 index 0000000..b8d88ce --- /dev/null +++ b/models/bid_tender_files.json @@ -0,0 +1,129 @@ +{ + "summary": [ + { + "name": "bid_tender_files", + "title": "招标文件", + "primary": [ + "id" + ], + "catelog": "entity" + } + ], + "fields": [ + { + "name": "id", + "title": "主键ID", + "type": "str", + "length": 32, + "nullable": "no" + }, + { + "name": "project_id", + "title": "项目ID", + "type": "str", + "length": 32, + "nullable": "no" + }, + { + "name": "tender_id", + "title": "招标信息ID", + "type": "str", + "length": 32 + }, + { + "name": "file_name", + "title": "文件名", + "type": "str", + "length": 255, + "nullable": "no" + }, + { + "name": "file_path", + "title": "文件路径", + "type": "str", + "length": 500 + }, + { + "name": "file_type", + "title": "文件类型", + "type": "str", + "length": 32, + "nullable": "no", + "default": "tender_doc" + }, + { + "name": "file_size", + "title": "文件大小", + "type": "int", + "default": "0" + }, + { + "name": "content_text", + "title": "正文提取", + "type": "text" + }, + { + "name": "extract_status", + "title": "抽取状态", + "type": "str", + "length": 16, + "nullable": "no", + "default": "pending" + }, + { + "name": "extract_note", + "title": "抽取说明", + "type": "str", + "length": 500 + }, + { + "name": "uploaded_by", + "title": "上传人", + "type": "str", + "length": 32 + }, + { + "name": "created_at", + "title": "创建时间", + "type": "timestamp", + "nullable": "no" + }, + { + "name": "updated_at", + "title": "更新时间", + "type": "timestamp" + } + ], + "indexes": [ + { + "name": "idx_btf_project", + "idxtype": "index", + "idxfields": [ + "project_id" + ] + }, + { + "name": "idx_btf_tender", + "idxtype": "index", + "idxfields": [ + "tender_id" + ] + }, + { + "name": "idx_btf_status", + "idxtype": "index", + "idxfields": [ + "extract_status" + ] + } + ], + "codes": [ + { + "field": "file_type", + "table": "appcodes_kv", + "valuefield": "k", + "textfield": "v", + "cond": "parentid='bid_file_type'" + } + ] +} diff --git a/models/bid_tenders.json b/models/bid_tenders.json new file mode 100644 index 0000000..f785573 --- /dev/null +++ b/models/bid_tenders.json @@ -0,0 +1,246 @@ +{ + "summary": [ + { + "name": "bid_tenders", + "title": "招标信息", + "primary": [ + "id" + ], + "catelog": "entity" + } + ], + "fields": [ + { + "name": "id", + "title": "主键ID", + "type": "str", + "length": 32, + "nullable": "no" + }, + { + "name": "org_id", + "title": "机构ID", + "type": "str", + "length": 32, + "nullable": "no", + "default": "0" + }, + { + "name": "source", + "title": "信息来源", + "type": "str", + "length": 16, + "nullable": "no", + "default": "mail" + }, + { + "name": "account_id", + "title": "邮箱配置ID", + "type": "str", + "length": 32 + }, + { + "name": "mail_uid", + "title": "邮件UID", + "type": "str", + "length": 64 + }, + { + "name": "mail_from", + "title": "发件人", + "type": "str", + "length": 255 + }, + { + "name": "mail_subject", + "title": "邮件主题", + "type": "str", + "length": 500 + }, + { + "name": "received_at", + "title": "收信时间", + "type": "timestamp" + }, + { + "name": "raw_content", + "title": "邮件原文", + "type": "text" + }, + { + "name": "attach_json", + "title": "附件清单(JSON)", + "type": "text" + }, + { + "name": "title", + "title": "项目名称", + "type": "str", + "length": 255 + }, + { + "name": "purchaser", + "title": "招标人/采购人", + "type": "str", + "length": 255 + }, + { + "name": "tender_no", + "title": "招标编号", + "type": "str", + "length": 64 + }, + { + "name": "industry", + "title": "行业", + "type": "str", + "length": 64 + }, + { + "name": "summary", + "title": "内容摘要", + "type": "text" + }, + { + "name": "bid_start_at", + "title": "投标开始时间", + "type": "date" + }, + { + "name": "bid_deadline", + "title": "投标截止时间", + "type": "date" + }, + { + "name": "project_period", + "title": "项目周期", + "type": "str", + "length": 64 + }, + { + "name": "budget_amount", + "title": "招标金额", + "type": "float", + "length": 18, + "dec": 2 + }, + { + "name": "deposit_amount", + "title": "投标保证金", + "type": "float", + "length": 18, + "dec": 2 + }, + { + "name": "qualification_req", + "title": "资质要求摘要", + "type": "text" + }, + { + "name": "key_dates", + "title": "关键时间节点(JSON)", + "type": "text" + }, + { + "name": "risk_note", + "title": "风险提示", + "type": "text" + }, + { + "name": "status", + "title": "状态", + "type": "str", + "length": 16, + "nullable": "no", + "default": "new" + }, + { + "name": "approval_task_id", + "title": "审批任务ID", + "type": "str", + "length": 32 + }, + { + "name": "project_id", + "title": "立项项目ID", + "type": "str", + "length": 32 + }, + { + "name": "archive_reason", + "title": "作废/存档原因", + "type": "str", + "length": 500 + }, + { + "name": "created_by", + "title": "创建人", + "type": "str", + "length": 32 + }, + { + "name": "created_at", + "title": "创建时间", + "type": "timestamp", + "nullable": "no" + }, + { + "name": "updated_at", + "title": "更新时间", + "type": "timestamp" + } + ], + "indexes": [ + { + "name": "idx_bt_org", + "idxtype": "index", + "idxfields": [ + "org_id" + ] + }, + { + "name": "idx_bt_status", + "idxtype": "index", + "idxfields": [ + "status" + ] + }, + { + "name": "idx_bt_mailuid", + "idxtype": "index", + "idxfields": [ + "mail_uid" + ] + }, + { + "name": "idx_bt_project", + "idxtype": "index", + "idxfields": [ + "project_id" + ] + }, + { + "name": "idx_bt_deadline", + "idxtype": "index", + "idxfields": [ + "bid_deadline" + ] + } + ], + "codes": [ + { + "field": "status", + "table": "appcodes_kv", + "valuefield": "k", + "textfield": "v", + "cond": "parentid='bid_tender_status'" + }, + { + "field": "source", + "table": "appcodes_kv", + "valuefield": "k", + "textfield": "v", + "cond": "parentid='bid_tender_source'" + } + ] +} diff --git a/mysql.ddl.sql b/mysql.ddl.sql new file mode 100644 index 0000000..90c5eb8 --- /dev/null +++ b/mysql.ddl.sql @@ -0,0 +1,526 @@ + +-- /home/ymq/work/pipeline/pipeline-bidding/models/bid_mail_accounts.json + + + + + +-- 建库时请用以下语句,支持emoji字符 +-- CREATE DATABASE mydb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +drop table if exists bid_mail_accounts; +CREATE TABLE bid_mail_accounts +( + + `id` VARCHAR(32) NOT NULL comment '主键ID', + `org_id` VARCHAR(32) NOT NULL DEFAULT '0' comment '机构ID', + `account_name` VARCHAR(128) NOT NULL comment '配置名称', + `protocol` VARCHAR(16) NOT NULL DEFAULT 'imap' comment '协议', + `host` VARCHAR(128) NOT NULL comment '服务器地址', + `port` int NOT NULL DEFAULT '993' comment '端口', + `use_ssl` VARCHAR(1) NOT NULL DEFAULT '1' comment '使用SSL', + `username` VARCHAR(128) NOT NULL comment '登录账号', + `password` VARCHAR(500) NOT NULL comment '登录密码(AES加密)', + `folder` VARCHAR(64) DEFAULT 'INBOX' comment '邮件目录', + `filter_keywords` VARCHAR(500) comment '招标关键词(逗号分隔)', + `fetch_days` int DEFAULT '7' comment '回溯天数', + `last_uid` VARCHAR(64) comment '上次同步UID', + `last_fetch_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP comment '上次同步时间', + `enabled` VARCHAR(1) NOT NULL DEFAULT '1' comment '启用', + `status` VARCHAR(16) DEFAULT 'active' comment '状态', + `created_by` VARCHAR(32) comment '创建人', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL comment '创建时间', + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP comment '更新时间' + + +,primary key(id) + + +) +CHARACTER SET utf8mb4 +COLLATE utf8mb4_unicode_ci +engine=innodb +comment '招标邮箱配置' +; + +CREATE INDEX bid_mail_accounts_idx_bma_org ON bid_mail_accounts(org_id); +CREATE INDEX bid_mail_accounts_idx_bma_enabled ON bid_mail_accounts(enabled); + +-- /home/ymq/work/pipeline/pipeline-bidding/models/bid_scoring_items.json + + + + + +-- 建库时请用以下语句,支持emoji字符 +-- CREATE DATABASE mydb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +drop table if exists bid_scoring_items; +CREATE TABLE bid_scoring_items +( + + `id` VARCHAR(32) NOT NULL comment '主键ID', + `project_id` VARCHAR(32) NOT NULL comment '项目ID', + `section` VARCHAR(32) NOT NULL DEFAULT 'technical' comment '评分部分', + `item_no` VARCHAR(32) comment '评分项编号', + `item_name` VARCHAR(255) NOT NULL comment '评分项名称', + `max_score` double(8,2) NOT NULL DEFAULT '0' comment '满分', + `scoring_rule` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci comment '得分规则', + `is_veto` VARCHAR(1) NOT NULL DEFAULT '0' comment '否决项', + `weight` double(8,4) comment '权重', + `source_ref` VARCHAR(500) comment '招标文件出处', + `target_chapter_no` VARCHAR(32) comment '对应章节号', + `order_no` int DEFAULT '0' comment '排序', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL comment '创建时间', + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP comment '更新时间' + + +,primary key(id) + + +) +CHARACTER SET utf8mb4 +COLLATE utf8mb4_unicode_ci +engine=innodb +comment '评分项与得分规则' +; + +CREATE INDEX bid_scoring_items_idx_bsi_project ON bid_scoring_items(project_id); +CREATE INDEX bid_scoring_items_idx_bsi_section ON bid_scoring_items(section); + +-- /home/ymq/work/pipeline/pipeline-bidding/models/bid_doc_requirements.json + + + + + +-- 建库时请用以下语句,支持emoji字符 +-- CREATE DATABASE mydb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +drop table if exists bid_doc_requirements; +CREATE TABLE bid_doc_requirements +( + + `id` VARCHAR(32) NOT NULL comment '主键ID', + `project_id` VARCHAR(32) NOT NULL comment '项目ID', + `req_type` VARCHAR(32) NOT NULL DEFAULT 'structure' comment '要求类型', + `chapter_no` VARCHAR(32) comment '章节号', + `chapter_title` VARCHAR(255) comment '章节标题', + `requirement` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci comment '要求内容', + `page_limit` int comment '页数限制', + `source_ref` VARCHAR(500) comment '招标文件出处', + `order_no` int DEFAULT '0' comment '排序', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL comment '创建时间', + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP comment '更新时间' + + +,primary key(id) + + +) +CHARACTER SET utf8mb4 +COLLATE utf8mb4_unicode_ci +engine=innodb +comment '投标文件要求' +; + +CREATE INDEX bid_doc_requirements_idx_bdr_project ON bid_doc_requirements(project_id); +CREATE INDEX bid_doc_requirements_idx_bdr_type ON bid_doc_requirements(req_type); + +-- /home/ymq/work/pipeline/pipeline-bidding/models/bid_members.json + + + + + +-- 建库时请用以下语句,支持emoji字符 +-- CREATE DATABASE mydb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +drop table if exists bid_members; +CREATE TABLE bid_members +( + + `id` VARCHAR(32) NOT NULL comment '主键ID', + `project_id` VARCHAR(32) NOT NULL comment '项目ID', + `user_id` VARCHAR(32) comment '用户ID', + `user_name` VARCHAR(128) comment '姓名', + `member_role` VARCHAR(64) NOT NULL DEFAULT 'writer' comment '承担角色', + `duty` VARCHAR(500) comment '职责说明', + `status` VARCHAR(16) DEFAULT 'active' comment '状态', + `created_by` VARCHAR(32) comment '创建人', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL comment '创建时间', + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP comment '更新时间' + + +,primary key(id) + + +) +CHARACTER SET utf8mb4 +COLLATE utf8mb4_unicode_ci +engine=innodb +comment '投标项目参与人员' +; + +CREATE INDEX bid_members_idx_bm_project ON bid_members(project_id); +CREATE INDEX bid_members_idx_bm_user ON bid_members(user_id); + +-- /home/ymq/work/pipeline/pipeline-bidding/models/bid_tender_files.json + + + + + +-- 建库时请用以下语句,支持emoji字符 +-- CREATE DATABASE mydb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +drop table if exists bid_tender_files; +CREATE TABLE bid_tender_files +( + + `id` VARCHAR(32) NOT NULL comment '主键ID', + `project_id` VARCHAR(32) NOT NULL comment '项目ID', + `tender_id` VARCHAR(32) comment '招标信息ID', + `file_name` VARCHAR(255) NOT NULL comment '文件名', + `file_path` VARCHAR(500) comment '文件路径', + `file_type` VARCHAR(32) NOT NULL DEFAULT 'tender_doc' comment '文件类型', + `file_size` int DEFAULT '0' comment '文件大小', + `content_text` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci comment '正文提取', + `extract_status` VARCHAR(16) NOT NULL DEFAULT 'pending' comment '抽取状态', + `extract_note` VARCHAR(500) comment '抽取说明', + `uploaded_by` VARCHAR(32) comment '上传人', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL comment '创建时间', + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP comment '更新时间' + + +,primary key(id) + + +) +CHARACTER SET utf8mb4 +COLLATE utf8mb4_unicode_ci +engine=innodb +comment '招标文件' +; + +CREATE INDEX bid_tender_files_idx_btf_project ON bid_tender_files(project_id); +CREATE INDEX bid_tender_files_idx_btf_tender ON bid_tender_files(tender_id); +CREATE INDEX bid_tender_files_idx_btf_status ON bid_tender_files(extract_status); + +-- /home/ymq/work/pipeline/pipeline-bidding/models/bid_chapters.json + + + + + +-- 建库时请用以下语句,支持emoji字符 +-- CREATE DATABASE mydb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +drop table if exists bid_chapters; +CREATE TABLE bid_chapters +( + + `id` VARCHAR(32) NOT NULL comment '主键ID', + `project_id` VARCHAR(32) NOT NULL comment '项目ID', + `chapter_no` VARCHAR(32) NOT NULL comment '章节号', + `parent_no` VARCHAR(32) comment '父章节号', + `title` VARCHAR(255) NOT NULL comment '章节标题', + `section` VARCHAR(32) DEFAULT 'technical' comment '所属部分', + `scoring_item_ids` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci comment '对应评分项(JSON)', + `outline` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci comment '章节要点', + `status` VARCHAR(16) NOT NULL DEFAULT 'pending' comment '章节状态', + `version` int NOT NULL DEFAULT '1' comment '版本号', + `content` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci comment '章节正文', + `file_path` VARCHAR(500) comment '章节文件路径', + `word_count` int DEFAULT '0' comment '字数', + `review_score` double(8,2) comment '评审得分', + `max_score` double(8,2) comment '对应满分', + `review_comment` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci comment '评审意见/改进意见', + `revise_count` int NOT NULL DEFAULT '0' comment '重做次数', + `assignee_role` VARCHAR(64) DEFAULT 'agent.bid_writer' comment '承办角色', + `task_id` VARCHAR(32) comment '关联任务ID', + `order_no` int DEFAULT '0' comment '排序', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL comment '创建时间', + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP comment '更新时间' + + +,primary key(id) + + +) +CHARACTER SET utf8mb4 +COLLATE utf8mb4_unicode_ci +engine=innodb +comment '投标文件章节' +; + +CREATE INDEX bid_chapters_idx_bc_project ON bid_chapters(project_id); +CREATE INDEX bid_chapters_idx_bc_status ON bid_chapters(status); +CREATE INDEX bid_chapters_idx_bc_no ON bid_chapters(project_id,chapter_no); + +-- /home/ymq/work/pipeline/pipeline-bidding/models/bid_reviews.json + + + + + +-- 建库时请用以下语句,支持emoji字符 +-- CREATE DATABASE mydb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +drop table if exists bid_reviews; +CREATE TABLE bid_reviews +( + + `id` VARCHAR(32) NOT NULL comment '主键ID', + `project_id` VARCHAR(32) NOT NULL comment '项目ID', + `scope` VARCHAR(16) NOT NULL DEFAULT 'chapter' comment '评审范围', + `chapter_id` VARCHAR(32) comment '章节ID', + `document_id` VARCHAR(32) comment '标书ID', + `round` int NOT NULL DEFAULT '1' comment '评审轮次', + `reviewer` VARCHAR(64) comment '评审方', + `review_type` VARCHAR(16) DEFAULT 'agent' comment '评审类型', + `score` double(8,2) comment '得分', + `max_score` double(8,2) comment '满分', + `pass_ratio` double(5,4) comment '通过门限', + `passed` VARCHAR(1) NOT NULL DEFAULT '0' comment '是否通过', + `comments` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci comment '改进意见', + `detail` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci comment '评审明细(JSON)', + `task_id` VARCHAR(32) comment '关联任务ID', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL comment '创建时间', + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP comment '更新时间' + + +,primary key(id) + + +) +CHARACTER SET utf8mb4 +COLLATE utf8mb4_unicode_ci +engine=innodb +comment '标书评审记录' +; + +CREATE INDEX bid_reviews_idx_br_project ON bid_reviews(project_id); +CREATE INDEX bid_reviews_idx_br_scope ON bid_reviews(scope); +CREATE INDEX bid_reviews_idx_br_chapter ON bid_reviews(chapter_id); + +-- /home/ymq/work/pipeline/pipeline-bidding/models/bid_tenders.json + + + + + +-- 建库时请用以下语句,支持emoji字符 +-- CREATE DATABASE mydb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +drop table if exists bid_tenders; +CREATE TABLE bid_tenders +( + + `id` VARCHAR(32) NOT NULL comment '主键ID', + `org_id` VARCHAR(32) NOT NULL DEFAULT '0' comment '机构ID', + `source` VARCHAR(16) NOT NULL DEFAULT 'mail' comment '信息来源', + `account_id` VARCHAR(32) comment '邮箱配置ID', + `mail_uid` VARCHAR(64) comment '邮件UID', + `mail_from` VARCHAR(255) comment '发件人', + `mail_subject` VARCHAR(500) comment '邮件主题', + `received_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP comment '收信时间', + `raw_content` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci comment '邮件原文', + `attach_json` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci comment '附件清单(JSON)', + `title` VARCHAR(255) comment '项目名称', + `purchaser` VARCHAR(255) comment '招标人/采购人', + `tender_no` VARCHAR(64) comment '招标编号', + `industry` VARCHAR(64) comment '行业', + `summary` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci comment '内容摘要', + `bid_start_at` date comment '投标开始时间', + `bid_deadline` date comment '投标截止时间', + `project_period` VARCHAR(64) comment '项目周期', + `budget_amount` double(18,2) comment '招标金额', + `deposit_amount` double(18,2) comment '投标保证金', + `qualification_req` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci comment '资质要求摘要', + `key_dates` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci comment '关键时间节点(JSON)', + `risk_note` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci comment '风险提示', + `status` VARCHAR(16) NOT NULL DEFAULT 'new' comment '状态', + `approval_task_id` VARCHAR(32) comment '审批任务ID', + `project_id` VARCHAR(32) comment '立项项目ID', + `archive_reason` VARCHAR(500) comment '作废/存档原因', + `created_by` VARCHAR(32) comment '创建人', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL comment '创建时间', + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP comment '更新时间' + + +,primary key(id) + + +) +CHARACTER SET utf8mb4 +COLLATE utf8mb4_unicode_ci +engine=innodb +comment '招标信息' +; + +CREATE INDEX bid_tenders_idx_bt_org ON bid_tenders(org_id); +CREATE INDEX bid_tenders_idx_bt_status ON bid_tenders(status); +CREATE INDEX bid_tenders_idx_bt_mailuid ON bid_tenders(mail_uid); +CREATE INDEX bid_tenders_idx_bt_project ON bid_tenders(project_id); +CREATE INDEX bid_tenders_idx_bt_deadline ON bid_tenders(bid_deadline); + +-- /home/ymq/work/pipeline/pipeline-bidding/models/bid_documents.json + + + + + +-- 建库时请用以下语句,支持emoji字符 +-- CREATE DATABASE mydb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +drop table if exists bid_documents; +CREATE TABLE bid_documents +( + + `id` VARCHAR(32) NOT NULL comment '主键ID', + `project_id` VARCHAR(32) NOT NULL comment '项目ID', + `version` int NOT NULL DEFAULT '1' comment '版本号', + `doc_name` VARCHAR(255) comment '标书名称', + `file_path` VARCHAR(500) comment '文件路径', + `page_count` int DEFAULT '0' comment '页数', + `chapter_count` int DEFAULT '0' comment '章节数', + `total_score` double(8,2) comment '总得分', + `max_score` double(8,2) comment '总满分', + `pass_ratio` double(5,4) comment '通过门限', + `status` VARCHAR(16) NOT NULL DEFAULT 'draft' comment '状态', + `review_id` VARCHAR(32) comment '评审记录ID', + `task_id` VARCHAR(32) comment '关联任务ID', + `created_by` VARCHAR(32) comment '创建人', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL comment '创建时间', + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP comment '更新时间' + + +,primary key(id) + + +) +CHARACTER SET utf8mb4 +COLLATE utf8mb4_unicode_ci +engine=innodb +comment '合成标书' +; + +CREATE INDEX bid_documents_idx_bdoc_project ON bid_documents(project_id); +CREATE INDEX bid_documents_idx_bdoc_status ON bid_documents(status); + +-- /home/ymq/work/pipeline/pipeline-bidding/models/bid_kb_docs.json + + + + + +-- 建库时请用以下语句,支持emoji字符 +-- CREATE DATABASE mydb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +drop table if exists bid_kb_docs; +CREATE TABLE bid_kb_docs +( + + `id` VARCHAR(32) NOT NULL comment '主键ID', + `org_id` VARCHAR(32) NOT NULL DEFAULT '0' comment '机构ID', + `doc_type` VARCHAR(32) NOT NULL DEFAULT 'qualification' comment '文档类型', + `doc_name` VARCHAR(255) NOT NULL comment '文档名称', + `doc_no` VARCHAR(64) comment '证书/合同编号', + `tags` VARCHAR(500) comment '标签(逗号分隔)', + `file_path` VARCHAR(500) comment '文件路径', + `content_text` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci comment '正文内容', + `issuer` VARCHAR(255) comment '颁发/签约方', + `customer` VARCHAR(255) comment '案例客户', + `contract_amount` double(18,2) comment '合同金额', + `valid_from` date comment '生效日期', + `valid_until` date comment '有效期至', + `status` VARCHAR(16) DEFAULT 'active' comment '状态', + `created_by` VARCHAR(32) comment '创建人', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL comment '创建时间', + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP comment '更新时间' + + +,primary key(id) + + +) +CHARACTER SET utf8mb4 +COLLATE utf8mb4_unicode_ci +engine=innodb +comment '公司投标知识库' +; + +CREATE INDEX bid_kb_docs_idx_bkd_org ON bid_kb_docs(org_id); +CREATE INDEX bid_kb_docs_idx_bkd_type ON bid_kb_docs(doc_type); +CREATE INDEX bid_kb_docs_idx_bkd_status ON bid_kb_docs(status); + +-- /home/ymq/work/pipeline/pipeline-bidding/models/bid_scores.json + + + + + +-- 建库时请用以下语句,支持emoji字符 +-- CREATE DATABASE mydb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +drop table if exists bid_scores; +CREATE TABLE bid_scores +( + + `id` VARCHAR(32) NOT NULL comment '主键ID', + `project_id` VARCHAR(32) NOT NULL comment '项目ID', + `review_id` VARCHAR(32) comment '评审记录ID', + `scoring_item_id` VARCHAR(32) comment '评分项ID', + `item_name` VARCHAR(255) comment '评分项名称', + `score` double(8,2) comment '得分', + `max_score` double(8,2) comment '满分', + `reason` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci comment '评分理由', + `improvement` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci comment '改进意见', + `target_chapter_id` VARCHAR(32) comment '待改章节ID', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL comment '创建时间', + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP comment '更新时间' + + +,primary key(id) + + +) +CHARACTER SET utf8mb4 +COLLATE utf8mb4_unicode_ci +engine=innodb +comment '标书评分明细' +; + +CREATE INDEX bid_scores_idx_bs_project ON bid_scores(project_id); +CREATE INDEX bid_scores_idx_bs_review ON bid_scores(review_id); +CREATE INDEX bid_scores_idx_bs_chapter ON bid_scores(target_chapter_id); + +-- /home/ymq/work/pipeline/pipeline-bidding/models/bid_qualifications.json + + + + + +-- 建库时请用以下语句,支持emoji字符 +-- CREATE DATABASE mydb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +drop table if exists bid_qualifications; +CREATE TABLE bid_qualifications +( + + `id` VARCHAR(32) NOT NULL comment '主键ID', + `project_id` VARCHAR(32) NOT NULL comment '项目ID', + `qual_name` VARCHAR(255) NOT NULL comment '资质名称', + `requirement` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci comment '资质要求原文', + `is_mandatory` VARCHAR(1) NOT NULL DEFAULT '1' comment '是否必需', + `owner` VARCHAR(16) NOT NULL DEFAULT 'kb' comment '提供方', + `kb_doc_id` VARCHAR(32) comment '知识库文档ID', + `match_status` VARCHAR(16) NOT NULL DEFAULT 'pending' comment '匹配状态', + `file_path` VARCHAR(500) comment '文档路径', + `note` VARCHAR(500) comment '说明', + `human_task_id` VARCHAR(32) comment '人类任务ID', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL comment '创建时间', + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP comment '更新时间' + + +,primary key(id) + + +) +CHARACTER SET utf8mb4 +COLLATE utf8mb4_unicode_ci +engine=innodb +comment '招标所需资质' +; + +CREATE INDEX bid_qualifications_idx_bq_project ON bid_qualifications(project_id); +CREATE INDEX bid_qualifications_idx_bq_match ON bid_qualifications(match_status); diff --git a/pipeline_bidding/__init__.py b/pipeline_bidding/__init__.py new file mode 100644 index 0000000..d34ff49 --- /dev/null +++ b/pipeline_bidding/__init__.py @@ -0,0 +1,9 @@ +"""pipeline_bidding — 投标产线模块(产线能力包,宿主不装则零接触)。 + +模块 = 数据(bid_* 表 + CRUD)+ 能力(bid_*_capability)+ 流转(bid_flow 对账器) ++ 主 agent 能力包(bid_ability)+ 角色技能(部署期随 pipeline-core skills_library 分发)。 +""" + +__version__ = "0.1.0" + +PIPELINE_ID = "bidding_general" diff --git a/pipeline_bidding/bid_ability.py b/pipeline_bidding/bid_ability.py new file mode 100644 index 0000000..d297178 --- /dev/null +++ b/pipeline_bidding/bid_ability.py @@ -0,0 +1,611 @@ +"""投标产线能力包(bidding_general):主 agent(驾驶舱)工具 + 角色定义 + 菜单 + slash 命令。 + +与开发产线(sdlc_general)同构,通过 pipeline-core 的 PipelineAbility 注册表挂载, +零侵入 core / AgentExecutor:项目 sd_projects.pipeline_id='bidding_general' 时自动生效。 + +角色任务流转不用 RoleSpec.next_role(全部留空),一律由 bid_flow 状态对账器驱动 —— 因为投标 +产线是「一章一任务 + 两级回退循环」的扇出结构,不是线性任务链。 +""" + +import json +import logging + +from pipeline_core import ( + ToolDefinition, PipelineAbility, RoleSpec, register_ability, + SlashCommand, register_slash_command, +) + +logger = logging.getLogger("pipeline.bidding.ability") + +PIPELINE_ID = "bidding_general" + +# ══════════════════ 工具定义(主 agent / 驾驶舱)══════════════════ + +BID_TOOLS = [ + # ── 招标情报 ── + ToolDefinition(name="fetch_tenders", description="从配置的招标邮箱采集招标信息入库(按关键词过滤+去重)", + parameters={"days": "回溯天数(可选,默认按邮箱配置)", "account_id": "邮箱配置ID(可选,默认全部启用)"}, + category="tender"), + ToolDefinition(name="import_tender", description="手工导入一条招标信息(邮箱不可用或线下拿到公告时用)", + parameters={"title": "项目名称", "content": "公告正文", "purchaser": "招标人(可选)", + "tender_no": "招标编号(可选)"}, category="tender"), + ToolDefinition(name="list_tenders", description="列出招标信息(可按状态筛选:new/summarized/pending_approval/approved/rejected/archived)", + parameters={"status": "状态(可选)", "limit": "条数(可选,默认50)"}, category="tender"), + ToolDefinition(name="tender_detail", description="查看一条招标信息详情(含公告正文)", + parameters={"tender_id": "招标信息ID"}, category="tender"), + ToolDefinition(name="summarize_tender", description="对招标信息做内容摘要与要素抽取(投标开始时间/项目周期/招标金额/保证金/资质要求)", + parameters={"tender_id": "招标信息ID"}, category="tender"), + ToolDefinition(name="set_tender_fields", description="修正招标信息要素(摘要抽错时人工纠正)", + parameters={"tender_id": "招标信息ID", "title": "项目名称(可选)", "purchaser": "招标人(可选)", + "bid_start_at": "投标开始时间(可选)", "bid_deadline": "投标截止(可选)", + "project_period": "项目周期(可选)", "budget_amount": "招标金额(可选)"}, + category="tender"), + ToolDefinition(name="submit_tender_approval", description="提交招标信息审批(生成人类审批任务,等待通过/不通过)", + parameters={"tender_id": "招标信息ID", "assignee_role": "审批角色(可选,默认owner.superuser)", + "note": "提交说明(可选)"}, category="tender"), + ToolDefinition(name="approve_tender", description="审批通过招标信息 → 自动立项并发布「上传招标文件」任务", + parameters={"tender_id": "招标信息ID", "comment": "审批意见(可选)"}, category="tender"), + ToolDefinition(name="reject_tender", description="审批不通过招标信息 → 该招标信息自动存档作废", + parameters={"tender_id": "招标信息ID", "comment": "不通过原因"}, category="tender"), + ToolDefinition(name="archive_tender", description="存档作废一条招标信息(不再跟进)", + parameters={"tender_id": "招标信息ID", "reason": "原因"}, category="tender"), + ToolDefinition(name="reopen_tender", description="重新评估已作废/已驳回的招标信息", + parameters={"tender_id": "招标信息ID", "reason": "原因(可选)"}, category="tender"), + + # ── 立项 / 人员 / 招标文件 ── + ToolDefinition(name="create_bid_project", description="由已审批通过的招标信息立项(创建投标项目)", + parameters={"tender_id": "招标信息ID", "name": "项目名称(可选)"}, category="project"), + ToolDefinition(name="assign_members", description="设定投标项目参与人员,如「张三:负责人,李四:writer」", + parameters={"members": "人员清单(姓名:角色 逗号分隔,或JSON数组)"}, category="project"), + ToolDefinition(name="list_members", description="查看投标项目参与人员", parameters={}, category="project"), + ToolDefinition(name="request_tender_file", description="发布/重发「等待上传招标文件」人类任务", + parameters={"note": "说明(可选)"}, category="project"), + ToolDefinition(name="list_tender_files", description="查看已上传的招标文件及抽取状态", parameters={}, category="project"), + + # ── 解析结果 ── + ToolDefinition(name="list_scoring_items", description="查看评分项与得分规则(从招标文件抽取的结果)", + parameters={"section": "评分部分(可选 business/technical/price)"}, category="analysis"), + ToolDefinition(name="list_doc_requirements", description="查看投标文件要求(章节结构/格式/密封/份数/截止)", + parameters={"req_type": "类型(可选 structure/format/seal/copies/deadline)"}, category="analysis"), + ToolDefinition(name="list_qualifications", description="查看招标所需资质及匹配状态(matched/missing/human_required)", + parameters={"match_status": "匹配状态(可选)"}, category="analysis"), + + # ── 知识库 ── + ToolDefinition(name="search_bid_kb", description="检索公司投标知识库(资质证书/业绩案例/模板/财务/人员)", + parameters={"query": "关键词", "doc_type": "类型(可选 qualification/cert/case/template/finance/personnel)"}, + category="kb"), + ToolDefinition(name="list_kb_docs", description="列出公司投标知识库文档", + parameters={"doc_type": "类型(可选)"}, category="kb"), + ToolDefinition(name="add_kb_doc", description="向公司投标知识库录入文档(资质证书/业绩案例等)", + parameters={"doc_name": "文档名称", "doc_type": "类型", "doc_no": "编号(可选)", + "tags": "标签(可选)", "file_path": "文件路径(可选)", "customer": "案例客户(可选)", + "contract_amount": "合同金额(可选)"}, category="kb"), + ToolDefinition(name="request_human_docs", description="发布「人类应提供的文件」清单任务(知识库缺失的资质由人工提供)", + parameters={"items": "清单(可选,默认自动汇总缺失资质)", "note": "说明(可选)"}, category="kb"), + + # ── 章节 / 标书 / 评分 ── + ToolDefinition(name="list_chapters", description="查看投标文件章节及状态(pending/writing/written/approved/rejected)", + parameters={"status": "状态(可选)"}, category="bid"), + ToolDefinition(name="chapter_detail", description="查看章节详情(要点/对应评分项/评审意见/正文)", + parameters={"chapter_id": "章节ID"}, category="bid"), + ToolDefinition(name="list_bid_documents", description="查看已合成的标书版本及评分", parameters={}, category="bid"), + ToolDefinition(name="list_reviews", description="查看评审记录(章节级/整书级)", + parameters={"scope": "范围(可选 chapter/whole)", "chapter_id": "章节ID(可选)"}, category="bid"), + ToolDefinition(name="bid_score_report", description="查看最近一轮整书评分报告(总分+逐项明细+改进意见)", + parameters={}, category="bid"), + + # ── 诊断 / 推进 ── + ToolDefinition(name="diagnose_bid", description="诊断投标项目:卡在哪个阶段、门禁、章节分布、在办任务、最近评分", + parameters={}, category="agent"), + ToolDefinition(name="advance_bid", description="立即触发投标流程状态对账(补派应有的角色任务,通常无需手动调用)", + parameters={}, category="agent"), +] + +# ══════════════════ 产线 prompt 片段 ══════════════════ + +BID_PROMPT = """你是「投标产线」的驾驶舱 agent,负责从招标信息到标书交付的全流程管理: +招标信息采集 → 内容摘要 → 审批 → 立项与人员 → 上传招标文件 → 解析(评分项/得分规则/资质/投标文件要求) +→ 资质文档准备 → 分章节编写 → 章节评审 → 合成标书 → 整书评分 → 交付确认。 + +## 流转由机制驱动(重要) +角色任务由系统状态对账器自动派发(每 15 秒一轮),你**不需要**手动为每个章节创建任务: +- 章节评审不达标 → 章节自动退回重写 +- 整书评分不达标 → 改进意见自动落到问题章节并退回重写 → 重新合成 → 重新评分 +你的职责是:采集与摘要、推动审批、立项与配人、处理卡点与提问、回答评审/评分中的人类决策。 +只有诊断到「机制该派任务却没派」时,才用 advance_bid 手动触发一次对账,或用 diagnose_bid 查根因。 + +## 阻塞门禁(不要绕过) +以下人类任务 pending 时,系统不会给该项目派发任何 agent 任务: +- 招标信息审批未决(tender_approval) +- 等待上传招标文件(tender_file_upload,上传后自动完成) +- 等待人类提供资质文件(human_docs_request) +遇到项目"不动了",先 diagnose_bid 看是不是卡在这三个门禁上,缺什么就催人补什么,不要硬推。 + +## 典型场景 +- 用户说「看看有什么新招标 / 收一下招标邮件」→ fetch_tenders,再 list_tenders 看结果。 +- 用户说「这条招标怎么样 / 帮我分析下」→ tender_detail + summarize_tender,把投标开始时间、 + 项目周期、招标金额、保证金、资质门槛、风险点讲清楚,再问是否提交审批。 +- 用户说「提交审批」→ submit_tender_approval;用户直接拍板通过/不通过 → approve_tender / reject_tender。 + **审批不通过就是终态**:招标信息自动存档作废,不要再立项。 +- 审批通过后 → 自动立项并发上传招标文件任务;接着 assign_members 设定参与人员。 +- 用户问进展 → diagnose_bid(含章节分布、在办任务、最近评分),不要只报"正在进行"。 +- 用户问「能得多少分 / 差在哪」→ bid_score_report + list_reviews,讲清失分项与改进意见落到了哪个章节。 +- 用户要补资质/案例 → add_kb_doc 录入公司投标知识库;知识库缺的用 request_human_docs 让人工提供。 + +## 硬规则 +- 招标金额、投标时间、资质要求这些要素,只能来自招标公告/招标文件原文或人工确认,**禁止编造**; + 抽取不到就如实说"公告未载明",必要时 ask_user。 +- 标书内容的案例与业绩必须来自公司投标知识库的真实合同(bid_kb_docs 里 doc_type=case), + 禁止虚构客户名、合同金额、历史成果数字。 +- 章节没全部评审通过,不允许合成标书;评分没过门限,不允许当作可交付。""" + +# ══════════════════ 角色集 ══════════════════ + +BID_ROLES = [ + RoleSpec( + name="agent.tender_scout", + description="招标信息员", + aliases=["tender_scout", "scout", "tender", "tender_intake"], + system_prompt="你是招标信息员。先 load_skill 加载 role 技能,按其中的职责与规范执行任务。", + next_role="", + task_title="招标信息采集与摘要", + ), + RoleSpec( + name="agent.tender_analyst", + description="招标文件分析师", + aliases=["tender_analyst", "analyst", "analysis"], + system_prompt="你是招标文件分析师。先 load_skill 加载 role 技能,按其中的职责与规范执行任务。", + next_role="", + task_title="招标文件解析", + ), + RoleSpec( + name="agent.bid_prep", + description="投标资料专员", + aliases=["bid_prep", "prep", "qualification"], + system_prompt="你是投标资料专员。先 load_skill 加载 role 技能,按其中的职责与规范执行任务。", + next_role="", + task_title="投标资料准备", + ), + RoleSpec( + name="agent.bid_writer", + description="标书撰写工程师", + aliases=["bid_writer", "writer", "write"], + system_prompt="你是标书撰写工程师。先 load_skill 加载 role 技能,按其中的职责与规范执行任务。", + next_role="", + task_title="章节编写", + ), + RoleSpec( + name="agent.bid_reviewer", + description="标书评审员", + aliases=["bid_reviewer", "reviewer", "chapter_review"], + system_prompt="你是标书评审员。先 load_skill 加载 role 技能,按其中的职责与规范执行任务。", + next_role="", + task_title="章节评审", + ), + RoleSpec( + name="agent.bid_compositor", + description="标书合成工程师", + aliases=["bid_compositor", "compositor", "compose"], + system_prompt="你是标书合成工程师。先 load_skill 加载 role 技能,按其中的职责与规范执行任务。", + next_role="", + task_title="标书合成", + ), + RoleSpec( + name="agent.bid_scorer", + description="标书评分员(模拟评标)", + aliases=["bid_scorer", "scorer", "score"], + system_prompt="你是标书评分员(模拟评标专家)。先 load_skill 加载 role 技能,按其中的职责与规范执行任务。", + next_role="", + task_title="整书评审与评分", + ), + RoleSpec( + name="agent.pm", + description="投标项目经理(任务验收)", + aliases=["pm", "manager"], + system_prompt="你是投标项目经理。先 load_skill 加载 role 技能,按其中的职责与规范验收任务。", + next_role="", + ), + RoleSpec( + name="agent.qc", + description="投标质量控制(合规检查)", + aliases=["qc", "quality"], + system_prompt="你是投标质量控制工程师。先 load_skill 加载 role 技能,按其中的职责与规范检查交付件。", + next_role="", + ), +] + + +# ══════════════════ handler(签名 async def handler(sor, params, ctx) -> str)══════════════════ + +def _need_project(ctx): + pid = ctx.get("project_id", "") or "" + return pid, ("请先切换到投标项目(用 switch_project 或在项目列表选择)" if not pid else "") + + +def _fmt(ok, msg): + return ("OK: " if ok else "ERROR: ") + str(msg) + + +def _fmt_rows(rows, empty="(空)"): + if not rows: + return empty + if isinstance(rows, str): + return rows + return "\n".join(json.dumps(r, ensure_ascii=False, default=str) for r in rows[:80]) + + +async def _h_fetch_tenders(sor, p, ctx): + from .bid_tender_capability import fetch_tenders + ok, msg = await fetch_tenders(org_id=ctx.get("org_id", "") or "", + account_id=p.get("account_id", "") or "", + days=p.get("days") or None, + who="agent.main_agent") + return _fmt(ok, msg) + + +async def _h_import_tender(sor, p, ctx): + from .bid_tender_capability import import_tender + ok, msg = await import_tender(p.get("title", ""), p.get("content", ""), + purchaser=p.get("purchaser", ""), + tender_no=p.get("tender_no", ""), + org_id=ctx.get("org_id", "") or "0", + who="agent.main_agent", + created_by=ctx.get("user_id", "") or "") + return _fmt(ok, "已导入招标信息 id=%s" % msg if ok else msg) + + +async def _h_list_tenders(sor, p, ctx): + from .bid_tender_capability import list_tenders + return _fmt_rows(await list_tenders(status=p.get("status", "") or "", + org_id=ctx.get("org_id", "") or "", + limit=p.get("limit") or 50), + "暂无招标信息(可用 fetch_tenders 采集或 import_tender 手工导入)") + + +async def _h_tender_detail(sor, p, ctx): + from .bid_tender_capability import tender_detail + return await tender_detail(p.get("tender_id", "")) + + +async def _h_summarize_tender(sor, p, ctx): + from .bid_tender_capability import summarize_tender + ok, msg = await summarize_tender(p.get("tender_id", ""), + model_name=ctx.get("model_name", "") or "", + who="agent.main_agent") + return _fmt(ok, msg) + + +async def _h_set_tender_fields(sor, p, ctx): + from .bid_tender_capability import set_tender_fields + ok, msg = await set_tender_fields( + p.get("tender_id", ""), title=p.get("title", ""), purchaser=p.get("purchaser", ""), + tender_no=p.get("tender_no", ""), bid_start_at=p.get("bid_start_at", ""), + bid_deadline=p.get("bid_deadline", ""), project_period=p.get("project_period", ""), + budget_amount=p.get("budget_amount", ""), deposit_amount=p.get("deposit_amount", ""), + summary=p.get("summary", ""), qualification_req=p.get("qualification_req", ""), + who="agent.main_agent") + return _fmt(ok, msg) + + +async def _h_submit_tender_approval(sor, p, ctx): + from .bid_tender_capability import submit_tender_approval + ok, msg = await submit_tender_approval( + p.get("tender_id", ""), assignee_role=p.get("assignee_role", "") or "owner.superuser", + note=p.get("note", ""), who="agent.main_agent") + return _fmt(ok, msg) + + +async def _h_approve_tender(sor, p, ctx): + from .bid_tender_capability import approve_tender + ok, msg = await approve_tender(p.get("tender_id", ""), comment=p.get("comment", ""), + who=ctx.get("user_id", "") or "owner.superuser") + return _fmt(ok, msg) + + +async def _h_reject_tender(sor, p, ctx): + from .bid_tender_capability import reject_tender + ok, msg = await reject_tender(p.get("tender_id", ""), comment=p.get("comment", ""), + who=ctx.get("user_id", "") or "owner.superuser") + return _fmt(ok, msg) + + +async def _h_archive_tender(sor, p, ctx): + from .bid_tender_capability import archive_tender + ok, msg = await archive_tender(p.get("tender_id", ""), reason=p.get("reason", ""), + who="agent.main_agent") + return _fmt(ok, msg) + + +async def _h_reopen_tender(sor, p, ctx): + from .bid_tender_capability import reopen_tender + ok, msg = await reopen_tender(p.get("tender_id", ""), reason=p.get("reason", ""), + who="agent.main_agent") + return _fmt(ok, msg) + + +async def _h_create_bid_project(sor, p, ctx): + from .bid_tender_capability import create_bid_project + ok, msg = await create_bid_project(p.get("tender_id", ""), name=p.get("name", ""), + org_id=ctx.get("org_id", "") or "", + created_by=ctx.get("user_id", "") or "", + who="agent.main_agent") + return _fmt(ok, msg) + + +async def _h_assign_members(sor, p, ctx): + pid, err = _need_project(ctx) + if err: + return err + from .bid_tender_capability import assign_members + ok, msg = await assign_members(pid, members=p.get("members", ""), who="agent.main_agent") + return _fmt(ok, msg) + + +async def _h_list_members(sor, p, ctx): + pid, err = _need_project(ctx) + if err: + return err + from .bid_tender_capability import list_members + return _fmt_rows(await list_members(pid), "尚未设定参与人员") + + +async def _h_request_tender_file(sor, p, ctx): + pid, err = _need_project(ctx) + if err: + return err + from .bid_tender_capability import request_tender_file + ok, msg = await request_tender_file(pid, note=p.get("note", ""), who="agent.main_agent") + return _fmt(ok, msg) + + +async def _h_list_tender_files(sor, p, ctx): + pid, err = _need_project(ctx) + if err: + return err + from .bid_tender_capability import list_tender_files + return _fmt_rows(await list_tender_files(pid), "尚未上传招标文件") + + +async def _h_list_scoring_items(sor, p, ctx): + pid, err = _need_project(ctx) + if err: + return err + from .bid_analysis_capability import list_scoring_items + return _fmt_rows(await list_scoring_items(pid, section=p.get("section", "") or ""), + "尚无评分项(等招标文件解析)") + + +async def _h_list_doc_requirements(sor, p, ctx): + pid, err = _need_project(ctx) + if err: + return err + from .bid_analysis_capability import list_doc_requirements + return _fmt_rows(await list_doc_requirements(pid, req_type=p.get("req_type", "") or ""), + "尚无投标文件要求(等招标文件解析)") + + +async def _h_list_qualifications(sor, p, ctx): + pid, err = _need_project(ctx) + if err: + return err + from .bid_kb_capability import list_qualifications + return _fmt_rows(await list_qualifications(pid, match_status=p.get("match_status", "") or ""), + "尚无资质要求(等招标文件解析)") + + +async def _h_search_bid_kb(sor, p, ctx): + from .bid_kb_capability import search_bid_kb + return _fmt_rows(await search_bid_kb(query=p.get("query", "") or "", + doc_type=p.get("doc_type", "") or "", + org_id=ctx.get("org_id", "") or ""), + "知识库无命中") + + +async def _h_list_kb_docs(sor, p, ctx): + from .bid_kb_capability import list_kb_docs + return _fmt_rows(await list_kb_docs(doc_type=p.get("doc_type", "") or "", + org_id=ctx.get("org_id", "") or ""), + "公司投标知识库为空(用 add_kb_doc 录入资质证书/业绩案例)") + + +async def _h_add_kb_doc(sor, p, ctx): + from .bid_kb_capability import add_kb_doc + ok, msg = await add_kb_doc( + p.get("doc_name", ""), doc_type=p.get("doc_type", "") or "qualification", + doc_no=p.get("doc_no", ""), tags=p.get("tags", ""), file_path=p.get("file_path", ""), + content_text=p.get("content_text", ""), issuer=p.get("issuer", ""), + customer=p.get("customer", ""), contract_amount=p.get("contract_amount", ""), + valid_until=p.get("valid_until", ""), org_id=ctx.get("org_id", "") or "0", + who="agent.main_agent") + return _fmt(ok, msg) + + +async def _h_request_human_docs(sor, p, ctx): + pid, err = _need_project(ctx) + if err: + return err + from .bid_kb_capability import request_human_docs + ok, msg = await request_human_docs(pid, items=p.get("items", ""), note=p.get("note", ""), + who="agent.main_agent") + return _fmt(ok, msg) + + +async def _h_list_chapters(sor, p, ctx): + pid, err = _need_project(ctx) + if err: + return err + from .bid_write_capability import list_chapters + return _fmt_rows(await list_chapters(pid, status=p.get("status", "") or ""), + "尚无章节(等招标文件解析生成章节骨架)") + + +async def _h_chapter_detail(sor, p, ctx): + from .bid_write_capability import chapter_detail + return await chapter_detail(p.get("chapter_id", "")) + + +async def _h_list_bid_documents(sor, p, ctx): + pid, err = _need_project(ctx) + if err: + return err + from .bid_compose_capability import list_bid_documents + return _fmt_rows(await list_bid_documents(pid), "尚未合成标书") + + +async def _h_list_reviews(sor, p, ctx): + pid, err = _need_project(ctx) + if err: + return err + from .bid_review_capability import list_reviews + return _fmt_rows(await list_reviews(pid, scope=p.get("scope", "") or "", + chapter_id=p.get("chapter_id", "") or ""), + "尚无评审记录") + + +async def _h_bid_score_report(sor, p, ctx): + pid, err = _need_project(ctx) + if err: + return err + from .bid_score_capability import bid_score_report + return await bid_score_report(pid) + + +async def _h_diagnose_bid(sor, p, ctx): + pid, err = _need_project(ctx) + if err: + return err + from .bid_flow import diagnose_bid_project + return await diagnose_bid_project(pid) + + +async def _h_advance_bid(sor, p, ctx): + pid, err = _need_project(ctx) + if err: + return err + from .bid_flow import reconcile_project + acts = await reconcile_project(sor, pid) + return "对账结果:\n" + "\n".join("- " + a for a in (acts or ["无动作"])) + + +BID_HANDLERS = { + "fetch_tenders": _h_fetch_tenders, + "import_tender": _h_import_tender, + "list_tenders": _h_list_tenders, + "tender_detail": _h_tender_detail, + "summarize_tender": _h_summarize_tender, + "set_tender_fields": _h_set_tender_fields, + "submit_tender_approval": _h_submit_tender_approval, + "approve_tender": _h_approve_tender, + "reject_tender": _h_reject_tender, + "archive_tender": _h_archive_tender, + "reopen_tender": _h_reopen_tender, + "create_bid_project": _h_create_bid_project, + "assign_members": _h_assign_members, + "list_members": _h_list_members, + "request_tender_file": _h_request_tender_file, + "list_tender_files": _h_list_tender_files, + "list_scoring_items": _h_list_scoring_items, + "list_doc_requirements": _h_list_doc_requirements, + "list_qualifications": _h_list_qualifications, + "search_bid_kb": _h_search_bid_kb, + "list_kb_docs": _h_list_kb_docs, + "add_kb_doc": _h_add_kb_doc, + "request_human_docs": _h_request_human_docs, + "list_chapters": _h_list_chapters, + "chapter_detail": _h_chapter_detail, + "list_bid_documents": _h_list_bid_documents, + "list_reviews": _h_list_reviews, + "bid_score_report": _h_bid_score_report, + "diagnose_bid": _h_diagnose_bid, + "advance_bid": _h_advance_bid, +} + + +def _merge_shared(): + """并入引擎级通用任务/交付件/问答/项目工具(不重复实现,见 pipeline_service.shared_ability)。""" + try: + from pipeline_service.shared_ability import SHARED_TOOLS, SHARED_HANDLERS + except Exception as e: # pipeline_service 未安装/版本旧 → 只用投标专属工具,诚实降级 + logger.warning("shared_ability 不可用,投标产线仅挂载专属工具: %s", str(e)[:120]) + return list(BID_TOOLS), dict(BID_HANDLERS) + names = {t.name for t in BID_TOOLS} + tools = list(BID_TOOLS) + [t for t in SHARED_TOOLS if t.name not in names] + handlers = dict(SHARED_HANDLERS) + handlers.update(BID_HANDLERS) # 同名以投标产线实现为准 + return tools, handlers + + +def register_bid_ability(): + """注册投标产线能力包(幂等)。""" + tools, handlers = _merge_shared() + ability = PipelineAbility( + pipeline_id=PIPELINE_ID, + name="通用投标产线", + tools=tools, + system_prompt=BID_PROMPT, + handlers=handlers, + roles=BID_ROLES, + menus=[ + {"label": "📨 招标信息", "icon": "", "url": "/pipeline-bidding/bid_tenders/index.ui", + "type": "popup", "width": "88%", "height": "82%"}, + {"label": "📑 评分项", "icon": "", "url": "/pipeline-bidding/bid_scoring_items/index.ui", + "type": "popup", "width": "88%", "height": "82%", "require_project": True}, + {"label": "📝 标书章节", "icon": "", "url": "/pipeline-bidding/bid_chapters/index.ui", + "type": "popup", "width": "88%", "height": "82%", "require_project": True}, + {"label": "🎯 资质", "icon": "", "url": "/pipeline-bidding/bid_qualifications/index.ui", + "type": "popup", "width": "88%", "height": "82%", "require_project": True}, + {"label": "📚 投标知识库", "icon": "", "url": "/pipeline-bidding/bid_kb_docs/index.ui", + "type": "popup", "width": "88%", "height": "82%"}, + {"label": "📦 标书", "icon": "", "url": "/pipeline-bidding/bid_documents/index.ui", + "type": "popup", "width": "88%", "height": "82%", "require_project": True}, + {"label": "✉️ 邮箱配置", "icon": "", "url": "/pipeline-bidding/bid_mail_accounts/index.ui", + "type": "popup", "width": "88%", "height": "82%"}, + ], + ) + register_ability(ability) + return ability + + +# ══════════════════ slash 命令(产线专属)══════════════════ + +async def _slash_tenders(args, ctx): + ex = ctx.get("executor") + if not ex: + return "无执行器上下文" + return await ex._execute_ability_tool("list_tenders", {"status": (args or "").strip()}) + + +async def _slash_bid(args, ctx): + ex = ctx.get("executor") + if not ex: + return "无执行器上下文" + return await ex._execute_ability_tool("diagnose_bid", {}) + + +async def _slash_chapters(args, ctx): + ex = ctx.get("executor") + if not ex: + return "无执行器上下文" + return await ex._execute_ability_tool("list_chapters", {"status": (args or "").strip()}) + + +async def _slash_score(args, ctx): + ex = ctx.get("executor") + if not ex: + return "无执行器上下文" + return await ex._execute_ability_tool("bid_score_report", {}) + + +def register_bid_slash_commands(): + for cmd in [ + SlashCommand("tenders", "列出招标信息(可带状态)", _slash_tenders, "pipeline", PIPELINE_ID), + SlashCommand("bid", "诊断当前投标项目卡点", _slash_bid, "pipeline", PIPELINE_ID), + SlashCommand("chapters", "列出标书章节及状态", _slash_chapters, "pipeline", PIPELINE_ID), + SlashCommand("score", "查看最近一轮整书评分报告", _slash_score, "pipeline", PIPELINE_ID), + ]: + register_slash_command(cmd) + + +# import 即注册(与 sdlc_ability 一致:宿主 import pipeline_bidding 即生效) +register_bid_ability() +register_bid_slash_commands() diff --git a/pipeline_bidding/bid_analysis_capability.py b/pipeline_bidding/bid_analysis_capability.py new file mode 100644 index 0000000..1af90a7 --- /dev/null +++ b/pipeline_bidding/bid_analysis_capability.py @@ -0,0 +1,444 @@ +"""招标文件解析能力:抽取评分项+得分规则、所需资质、投标文件要求,生成章节骨架。 + +对应业务:招标文件获得后,从招标文件中抽取评分项以及得分规则、所需资质、投标文件要求。 +章节骨架规则(写进 bid-doc-spec 技能):章节子项直接来自评分标准原文枚举的子维度,不是通用目录。 +""" + +import json +import logging + +from .bid_common import ( + get_db, new_id, rec_to_dict, rows_to_dicts, to_float, to_int, json_loads, + llm_json, record, resolve_project_id, CH_PENDING, +) + +logger = logging.getLogger("pipeline.bidding.analysis") + +EXTRACT_PROMPT = """你是招标文件分析师。下面是招标文件正文(可能被截断),请抽取三类结构化信息。 + +【招标文件正文】 +__CONTENT__ + +严格只输出一个 JSON 对象: +{ + "scoring_items": [ + {"section": "business|technical|price", "item_no": "评分项编号", "item_name": "评分项名称", + "max_score": 数字满分, "scoring_rule": "得分规则原文(含档位/扣分/加分细则)", + "is_veto": "0或1(是否否决项/废标项)", "source_ref": "出处,如 第三章 评标办法 表1"} + ], + "qualifications": [ + {"qual_name": "资质名称", "requirement": "要求原文", "is_mandatory": "0或1"} + ], + "doc_requirements": [ + {"req_type": "structure|format|seal|copies|deadline|other", + "chapter_no": "章节号(structure 类必填,如 1 / 1.1)", + "chapter_title": "章节标题(structure 类必填)", + "requirement": "要求内容", "page_limit": 数字或null, "source_ref": "出处"} + ] +} +要求: +1. scoring_items 必须逐条覆盖评标办法中的每个评分项,满分数字准确;否决项/废标条款 is_veto=1。 +2. doc_requirements 中 req_type=structure 的条目就是投标文件应有的章节结构,按招标文件要求的顺序给出。 +3. 只抽取正文中真实存在的内容,缺失就不要输出该条,禁止编造。""" + + +# ══════════════ 招标文件读取 ══════════════ + +async def list_tender_files(project_id, extract_status=""): + """列出项目的招标文件。""" + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + sql = ("SELECT id, file_name, file_type, extract_status, file_size, file_path " + "FROM bid_tender_files WHERE project_id=${pid}$") + p = {"pid": project_id} + if extract_status: + sql += " AND extract_status=${st}$" + p["st"] = extract_status + sql += " ORDER BY created_at" + recs = await sor.sqlExe(sql, p) + await sor.sqlExe("COMMIT", {}) + return rows_to_dicts(recs) + + +async def read_tender_file(file_id, offset=0, length=20000): + """读招标文件正文(分段读,供 LLM 分析)。正文为空时尝试从磁盘提取。""" + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + recs = await sor.sqlExe( + "SELECT id, project_id, file_name, file_path, content_text " + "FROM bid_tender_files WHERE id=${fid}$", {"fid": file_id}) + await sor.sqlExe("COMMIT", {}) + if not recs: + return "招标文件不存在: %s" % file_id + d = rec_to_dict(recs[0]) + txt = d.get("content_text") or "" + if not txt.strip() and d.get("file_path"): + txt = _extract_file_text(d["file_path"]) + if txt: + await sor.sqlExe( + "UPDATE bid_tender_files SET content_text=${c}$, updated_at=NOW() " + "WHERE id=${fid}$", {"c": txt[:2000000], "fid": file_id}) + await sor.sqlExe("COMMIT", {}) + if not txt.strip(): + return ("招标文件正文为空(file_path=%s)。请确认文件已上传且格式为 docx/pdf/txt。" + % d.get("file_path", "")) + o = to_int(offset, 0) + n = to_int(length, 20000) + seg = txt[o:o + n] + return ("【%s】字符 %d-%d / 共 %d\n%s" + % (d.get("file_name", ""), o, o + len(seg), len(txt), seg)) + + +def _extract_file_text(path): + """从 docx/pdf/txt 提取纯文本(无依赖时返回空串,不抛异常)。""" + import os + import re + import zipfile + if not path or not os.path.isfile(path): + return "" + ext = os.path.splitext(path)[1].lower() + try: + if ext in (".txt", ".md", ".csv", ".json"): + with open(path, "r", encoding="utf-8", errors="ignore") as f: + return f.read() + if ext == ".docx": + with zipfile.ZipFile(path) as z: + out = [] + for name in z.namelist(): + if not (name.startswith("word/") and name.endswith(".xml")): + continue + if "document" not in name and "header" not in name and "footer" not in name: + continue + xml = z.read(name).decode("utf-8", errors="ignore") + xml = xml.replace("", "\n") + out.append("".join(re.findall(r"]*>(.*?)", xml, flags=re.S))) + return "\n".join(out) + if ext == ".pdf": + try: + import fitz + doc = fitz.open(path) + return "\n".join(pg.get_text() for pg in doc) + except ImportError: + return "" + except Exception as e: + logger.warning("extract file text failed %s: %s", path, str(e)[:120]) + return "" + + +async def mark_file_extracted(file_id, note="", who=None, agent_id=None): + """标记招标文件已抽取完成。""" + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + await sor.sqlExe( + "UPDATE bid_tender_files SET extract_status='extracted', extract_note=${n}$, " + "updated_at=NOW() WHERE id=${fid}$", {"n": (note or "")[:480], "fid": file_id}) + await sor.sqlExe("COMMIT", {}) + return True, "已标记抽取完成: %s" % file_id + + +# ══════════════ 抽取落库(逐条) ══════════════ + +async def add_scoring_item(project_id, item_name, max_score, scoring_rule="", + section="technical", item_no="", is_veto="0", + source_ref="", target_chapter_no="", weight="", + who=None, agent_id=None): + """新增一个评分项及得分规则。""" + try: + project_id = await resolve_project_id(project_id) + except ValueError as e: + return False, str(e) + if not item_name: + return False, "缺少 item_name" + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + exist = await sor.sqlExe( + "SELECT id FROM bid_scoring_items WHERE project_id=${pid}$ AND item_name=${nm}$ LIMIT 1", + {"pid": project_id, "nm": str(item_name)[:250]}) + await sor.sqlExe("COMMIT", {}) + if exist: + return True, "评分项已存在: %s" % item_name + n = await sor.sqlExe( + "SELECT COUNT(*) AS c FROM bid_scoring_items WHERE project_id=${pid}$", + {"pid": project_id}) + order_no = to_int(getattr(n[0], "c", 0) if n else 0) + 1 + sid = new_id() + await sor.C("bid_scoring_items", { + "id": sid, "project_id": project_id, + "section": (section or "technical")[:30], + "item_no": str(item_no or "")[:30], + "item_name": str(item_name)[:250], + "max_score": to_float(max_score, 0.0), + "scoring_rule": scoring_rule or "", + "is_veto": "1" if str(is_veto) in ("1", "true", "True", "是") else "0", + "weight": to_float(weight, 0.0) or None, + "source_ref": str(source_ref or "")[:480], + "target_chapter_no": str(target_chapter_no or "")[:30], + "order_no": order_no, + }) + await sor.sqlExe("COMMIT", {}) + await record(sor, project_id, "bid_scoring_items", sid, "add", + who=who, agent_id=agent_id, detail=str(item_name)[:200]) + return True, "评分项已录入: %s (%s分)" % (item_name, max_score) + + +async def add_qualification(project_id, qual_name, requirement="", is_mandatory="1", + owner="kb", who=None, agent_id=None): + """新增一项招标所需资质。""" + try: + project_id = await resolve_project_id(project_id) + except ValueError as e: + return False, str(e) + if not qual_name: + return False, "缺少 qual_name" + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + exist = await sor.sqlExe( + "SELECT id FROM bid_qualifications WHERE project_id=${pid}$ AND qual_name=${nm}$ LIMIT 1", + {"pid": project_id, "nm": str(qual_name)[:250]}) + await sor.sqlExe("COMMIT", {}) + if exist: + return True, "资质项已存在: %s" % qual_name + qid = new_id() + await sor.C("bid_qualifications", { + "id": qid, "project_id": project_id, + "qual_name": str(qual_name)[:250], + "requirement": requirement or "", + "is_mandatory": "1" if str(is_mandatory) in ("1", "true", "True", "是") else "0", + "owner": (owner or "kb")[:15], + "match_status": "pending", + }) + await sor.sqlExe("COMMIT", {}) + await record(sor, project_id, "bid_qualifications", qid, "add", + who=who, agent_id=agent_id, detail=str(qual_name)[:200]) + return True, "资质要求已录入: %s" % qual_name + + +async def add_doc_requirement(project_id, requirement, req_type="structure", + chapter_no="", chapter_title="", page_limit="", + source_ref="", who=None, agent_id=None): + """新增一条投标文件要求(structure 类同时是章节骨架来源)。""" + try: + project_id = await resolve_project_id(project_id) + except ValueError as e: + return False, str(e) + if not requirement: + return False, "缺少 requirement" + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + n = await sor.sqlExe( + "SELECT COUNT(*) AS c FROM bid_doc_requirements WHERE project_id=${pid}$", + {"pid": project_id}) + await sor.sqlExe("COMMIT", {}) + order_no = to_int(getattr(n[0], "c", 0) if n else 0) + 1 + rid = new_id() + await sor.C("bid_doc_requirements", { + "id": rid, "project_id": project_id, + "req_type": (req_type or "structure")[:30], + "chapter_no": str(chapter_no or "")[:30], + "chapter_title": str(chapter_title or "")[:250], + "requirement": requirement or "", + "page_limit": to_int(page_limit, 0) or None, + "source_ref": str(source_ref or "")[:480], + "order_no": order_no, + }) + await sor.sqlExe("COMMIT", {}) + return True, "投标文件要求已录入: %s %s" % (req_type, chapter_title or "") + + +async def extract_from_file(project_id, file_id="", model_name="", offset=0, + length=30000, who=None, agent_id=None): + """LLM 一次性抽取三类信息并批量落库(评分项/资质/投标文件要求)。 + + 大文件分段调用(offset 递增),重复项按名称去重,可安全多次调用。 + """ + try: + project_id = await resolve_project_id(project_id, file_id=file_id) + except ValueError as e: + return False, str(e) + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + if file_id: + recs = await sor.sqlExe( + "SELECT id, content_text, file_path, file_name FROM bid_tender_files " + "WHERE id=${fid}$", {"fid": file_id}) + else: + recs = await sor.sqlExe( + "SELECT id, content_text, file_path, file_name FROM bid_tender_files " + "WHERE project_id=${pid}$ AND file_type='tender_doc' ORDER BY created_at LIMIT 1", + {"pid": project_id}) + await sor.sqlExe("COMMIT", {}) + if not recs: + return False, "项目下没有招标文件(bid_tender_files),无法抽取" + d = rec_to_dict(recs[0]) + txt = d.get("content_text") or "" + if not txt.strip() and d.get("file_path"): + txt = _extract_file_text(d["file_path"]) + if txt: + await sor.sqlExe( + "UPDATE bid_tender_files SET content_text=${c}$ WHERE id=${fid}$", + {"c": txt[:2000000], "fid": d["id"]}) + await sor.sqlExe("COMMIT", {}) + if not txt.strip(): + return False, "招标文件正文为空,无法抽取(file=%s)" % d.get("file_name", "") + o = to_int(offset, 0) + seg = txt[o:o + to_int(length, 30000)] + data, err = await llm_json(EXTRACT_PROMPT.replace("__CONTENT__", seg), + model_name=model_name, retries=1) + if data is None: + return False, "LLM 抽取失败: %s" % err + cnt = {"scoring_items": 0, "qualifications": 0, "doc_requirements": 0} + for it in (data.get("scoring_items") or []): + if not isinstance(it, dict) or not it.get("item_name"): + continue + ok, _ = await add_scoring_item( + project_id, it.get("item_name"), it.get("max_score", 0), + scoring_rule=it.get("scoring_rule", ""), section=it.get("section", "technical"), + item_no=it.get("item_no", ""), is_veto=it.get("is_veto", "0"), + source_ref=it.get("source_ref", ""), who=who, agent_id=agent_id) + cnt["scoring_items"] += 1 if ok else 0 + for it in (data.get("qualifications") or []): + if not isinstance(it, dict) or not it.get("qual_name"): + continue + ok, _ = await add_qualification( + project_id, it.get("qual_name"), requirement=it.get("requirement", ""), + is_mandatory=it.get("is_mandatory", "1"), who=who, agent_id=agent_id) + cnt["qualifications"] += 1 if ok else 0 + for it in (data.get("doc_requirements") or []): + if not isinstance(it, dict) or not it.get("requirement"): + continue + ok, _ = await add_doc_requirement( + project_id, it.get("requirement"), req_type=it.get("req_type", "structure"), + chapter_no=it.get("chapter_no", ""), chapter_title=it.get("chapter_title", ""), + page_limit=it.get("page_limit", ""), source_ref=it.get("source_ref", ""), + who=who, agent_id=agent_id) + cnt["doc_requirements"] += 1 if ok else 0 + return True, ("抽取完成(文件 %s,字符 %d-%d):评分项 %d,资质 %d,投标文件要求 %d" + % (d.get("file_name", ""), o, o + len(seg), + cnt["scoring_items"], cnt["qualifications"], cnt["doc_requirements"])) + + +# ══════════════ 查询 ══════════════ + +async def list_scoring_items(project_id, section=""): + try: + project_id = await resolve_project_id(project_id) + except ValueError as e: + return [] + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + sql = ("SELECT id, section, item_no, item_name, max_score, is_veto, " + "target_chapter_no, scoring_rule FROM bid_scoring_items WHERE project_id=${pid}$") + p = {"pid": project_id} + if section: + sql += " AND section=${sec}$" + p["sec"] = section + sql += " ORDER BY section, order_no" + recs = await sor.sqlExe(sql, p) + await sor.sqlExe("COMMIT", {}) + out = rows_to_dicts(recs) + for r in out: + r["scoring_rule"] = (r.get("scoring_rule") or "")[:600] + return out + + +async def list_doc_requirements(project_id, req_type=""): + try: + project_id = await resolve_project_id(project_id) + except ValueError as e: + return [] + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + sql = ("SELECT id, req_type, chapter_no, chapter_title, requirement, page_limit " + "FROM bid_doc_requirements WHERE project_id=${pid}$") + p = {"pid": project_id} + if req_type: + sql += " AND req_type=${rt}$" + p["rt"] = req_type + sql += " ORDER BY req_type, order_no" + recs = await sor.sqlExe(sql, p) + await sor.sqlExe("COMMIT", {}) + out = rows_to_dicts(recs) + for r in out: + r["requirement"] = (r.get("requirement") or "")[:600] + return out + + +# ══════════════ 章节骨架 ══════════════ + +async def create_chapter_outline(project_id, chapters="", who=None, agent_id=None): + """生成章节骨架 → bid_chapters(status=pending)。 + + chapters 为空时:自动用 bid_doc_requirements(req_type=structure) 生成, + 并按 target_chapter_no / 章节标题关键词把评分项挂到章节上(scoring_item_ids + max_score)。 + """ + try: + project_id = await resolve_project_id(project_id) + except ValueError as e: + return False, str(e) + items = json_loads(chapters, []) if chapters else [] + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + if not items: + recs = await sor.sqlExe( + "SELECT chapter_no, chapter_title, requirement, page_limit FROM bid_doc_requirements " + "WHERE project_id=${pid}$ AND req_type='structure' ORDER BY order_no", + {"pid": project_id}) + await sor.sqlExe("COMMIT", {}) + for r in (recs or []): + d = rec_to_dict(r) + if not (d.get("chapter_title") or d.get("chapter_no")): + continue + items.append({ + "chapter_no": d.get("chapter_no") or "", + "title": d.get("chapter_title") or "", + "outline": (d.get("requirement") or "")[:4000], + }) + if not items: + return False, ("无章节来源:请先抽取 req_type=structure 的投标文件要求," + "或显式传 chapters=[{\"chapter_no\":\"1\",\"title\":\"...\"}]") + + sitems = await sor.sqlExe( + "SELECT id, item_name, max_score, section, target_chapter_no " + "FROM bid_scoring_items WHERE project_id=${pid}$", {"pid": project_id}) + await sor.sqlExe("COMMIT", {}) + sc = rows_to_dicts(sitems) + + created, skipped = 0, 0 + for idx, it in enumerate(items): + if not isinstance(it, dict): + continue + cno = str(it.get("chapter_no") or (idx + 1))[:30] + title = str(it.get("title") or it.get("chapter_title") or "").strip()[:250] + if not title: + continue + exist = await sor.sqlExe( + "SELECT id FROM bid_chapters WHERE project_id=${pid}$ AND chapter_no=${cno}$ LIMIT 1", + {"pid": project_id, "cno": cno}) + await sor.sqlExe("COMMIT", {}) + if exist: + skipped += 1 + continue + matched, msum = [], 0.0 + for s in sc: + tgt = (s.get("target_chapter_no") or "").strip() + nm = s.get("item_name") or "" + if (tgt and tgt == cno) or (nm and (nm in title or title in nm)): + matched.append(s["id"]) + msum += to_float(s.get("max_score"), 0.0) + await sor.C("bid_chapters", { + "id": new_id(), "project_id": project_id, + "chapter_no": cno, "parent_no": str(it.get("parent_no") or "")[:30], + "title": title, "section": str(it.get("section") or "technical")[:30], + "scoring_item_ids": json.dumps(matched, ensure_ascii=False), + "outline": str(it.get("outline") or "")[:20000], + "status": CH_PENDING, "version": 1, "revise_count": 0, + "max_score": msum or None, + "assignee_role": "agent.bid_writer", + "order_no": idx + 1, + }) + created += 1 + await sor.sqlExe("COMMIT", {}) + await record(sor, project_id, "bid_chapters", project_id, "create_outline", + who=who, agent_id=agent_id, detail="新增 %d 章" % created) + return True, ("章节骨架已生成:新增 %d 章,已存在跳过 %d 章。" + "系统将自动为每个章节派发编写任务。" % (created, skipped)) diff --git a/pipeline_bidding/bid_common.py b/pipeline_bidding/bid_common.py new file mode 100644 index 0000000..7a21ead --- /dev/null +++ b/pipeline_bidding/bid_common.py @@ -0,0 +1,333 @@ +"""投标产线公共层:DB 上下文、角色规范化、门限参数、LLM 调用、项目/招标信息取数。 + +所有 bid_* capability 模块共用本模块,禁止各自重复实现(dspy/capability 都只调这里)。 +""" + +import json +import logging + +from sqlor.dbpools import DBPools +from appPublic.uniqueID import getID + +DBNAME = "pipeline" +PIPELINE_ID = "bidding_general" + +logger = logging.getLogger("pipeline.bidding") + +# ── 章节 / 标书状态 ── +CH_PENDING = "pending" +CH_WRITING = "writing" +CH_WRITTEN = "written" +CH_REVIEWING = "reviewing" +CH_APPROVED = "approved" +CH_REJECTED = "rejected" + +# ── 招标信息状态 ── +TD_NEW = "new" +TD_SUMMARIZED = "summarized" +TD_PENDING_APPROVAL = "pending_approval" +TD_APPROVED = "approved" +TD_REJECTED = "rejected" +TD_ARCHIVED = "archived" + +# ── 标书状态 ── +DOC_DRAFT = "draft" +DOC_REVIEWING = "reviewing" +DOC_PASSED = "passed" +DOC_REJECTED = "rejected" +DOC_DELIVERED = "delivered" + +# ── 人类任务类型(阻塞门禁)── +HT_TENDER_APPROVAL = "tender_approval" +HT_TENDER_FILE = "tender_file_upload" +HT_HUMAN_DOCS = "human_docs_request" +HT_DELIVERY_CONFIRM = "bid_delivery_confirm" + +BLOCKING_HT_TYPES = (HT_TENDER_FILE, HT_HUMAN_DOCS) + +# 任务未终结状态(判断「是否已有在办任务」用) +OPEN_TASK_STATES = ("submitted", "running", "review", "qc_review") + +# ── 门限默认值(可用 appbase params 表覆盖)── +DEFAULT_PARAMS = { + "bid_chapter_pass_ratio": "0.8", # 章节评审通过门限 + "bid_pass_ratio": "0.85", # 整书评分通过门限 + "bid_max_revise": "3", # 单章节最大重做次数 + "bid_max_round": "3", # 整书最大评分轮次 +} + + +def get_db(): + """取 DBPools(databases 为空时从 config 兜底注入,与引擎其他模块一致)。""" + db = DBPools() + if not db.databases: + from appPublic.jsonConfig import getConfig + config = getConfig() + if config and config.databases: + db.databases = config.databases + return db, DBNAME + + +def new_id(): + return getID() + + +def normalize_role(role): + """agent 角色补 agent. 前缀;人角色 {orgtype}.{role} 原样返回。""" + role = (role or "").strip() + if not role: + return "" + if "." in role: + return role + return "agent." + role + + +def rec_to_dict(rec): + """sqlor 行对象 → dict(过滤方法名,避免 DictObject 序列化泄漏内置方法)。""" + if rec is None: + return {} + if isinstance(rec, dict): + return {k: v for k, v in rec.items() if not callable(v)} + d = {} + for k, v in vars(rec).items(): + if k.startswith("_") or callable(v): + continue + d[k] = v + return d + + +def rows_to_dicts(recs, limit=200): + return [rec_to_dict(r) for r in (recs or [])[:limit]] + + +def to_float(v, default=0.0): + try: + if v is None or v == "": + return default + return float(v) + except (TypeError, ValueError): + return default + + +def to_int(v, default=0): + try: + if v is None or v == "": + return default + return int(float(v)) + except (TypeError, ValueError): + return default + + +def json_loads(s, default=None): + if not s: + return default if default is not None else [] + if isinstance(s, (list, dict)): + return s + try: + return json.loads(s) + except (json.JSONDecodeError, TypeError, ValueError): + return default if default is not None else [] + + +async def get_param(sor, key, default=""): + """读 appbase params 表配置(复用引擎 workspace.get_param,不重复实现)+ 默认兜底。""" + try: + from pipeline_service.workspace import get_param as _gp + v = await _gp(sor, key, "") + if v not in (None, ""): + return str(v) + except Exception: + pass + return str(DEFAULT_PARAMS.get(key, default)) + + +async def resolve_project_id(project_id="", **entity_ids): + """补齐 project_id:调用方(会话上下文)已给则原样返回;否则从实体反查项目。 + + 角色工具经引擎分发时默认注入会话当前项目;为空时(无会话上下文)按实体反查, + 避免「项目不明」导致跨项目读写。全部反查失败抛 ValueError。 + """ + if project_id: + return project_id + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + for tbl, col, val in (("bid_chapters", "chapter_id", entity_ids.get("chapter_id")), + ("bid_reviews", "review_id", entity_ids.get("review_id")), + ("bid_documents", "document_id", entity_ids.get("document_id")), + ("bid_tender_files", "file_id", entity_ids.get("file_id"))): + if not val: + continue + recs = await sor.sqlExe( + "SELECT project_id FROM " + tbl + " WHERE id=${v}$ LIMIT 1", {"v": val}) + await sor.sqlExe("COMMIT", {}) + if recs and getattr(recs[0], "project_id", ""): + return getattr(recs[0], "project_id", "") + raise ValueError("缺少 project_id(请指定项目或切换到项目)") + + +async def get_thresholds(sor): + """取四个门限参数(章节门限/整书门限/章节重做上限/整书轮次上限)。""" + return { + "chapter_pass_ratio": to_float(await get_param(sor, "bid_chapter_pass_ratio"), 0.8), + "bid_pass_ratio": to_float(await get_param(sor, "bid_pass_ratio"), 0.85), + "max_revise": to_int(await get_param(sor, "bid_max_revise"), 3), + "max_round": to_int(await get_param(sor, "bid_max_round"), 3), + } + + +async def get_project(sor, project_id): + """取项目记录(dict),不存在返回 {}。""" + if not project_id: + return {} + recs = await sor.sqlExe( + "SELECT id, name, org_id, status, pipeline_id, created_by, default_model " + "FROM sd_projects WHERE id=${pid}$", {"pid": project_id}) + await sor.sqlExe("COMMIT", {}) + return rec_to_dict(recs[0]) if recs else {} + + +async def get_tender_by_project(sor, project_id): + """取项目关联的招标信息(dict)。""" + if not project_id: + return {} + recs = await sor.sqlExe( + "SELECT * FROM bid_tenders WHERE project_id=${pid}$ ORDER BY created_at DESC LIMIT 1", + {"pid": project_id}) + await sor.sqlExe("COMMIT", {}) + return rec_to_dict(recs[0]) if recs else {} + + +async def create_role_task(sor, project_id, role, title, params=None, owner_id="bid_flow"): + """创建角色任务(pipeline_tasks,tenant_id=project_id,pipeline_id='role_task')。 + + 与开发产线共用同一套任务表和 poller —— 投标产线不另建任务机制。 + """ + tid = new_id() + await sor.C("pipeline_tasks", { + "id": tid, + "tenant_id": project_id, + "pipeline_id": "role_task", + "owner_id": owner_id, + "title": (title or "")[:250], + "params": json.dumps(params or {}, ensure_ascii=False), + "role": normalize_role(role), + "state": "submitted", + "claimed_by": None, + }) + logger.info("bid create_role_task role=%s task=%s project=%s", role, tid, project_id) + return tid + + +async def count_open_tasks(sor, project_id, role=None, chapter_id=None): + """统计项目下未终结任务数(可按角色/章节过滤)。用于「已有在办任务就不重复派」。""" + states = "','".join(OPEN_TASK_STATES) + sql = ("SELECT id, params FROM pipeline_tasks WHERE tenant_id=${pid}$ " + "AND pipeline_id='role_task' AND state IN ('" + states + "')") + p = {"pid": project_id} + if role: + sql += " AND role=${role}$" + p["role"] = normalize_role(role) + recs = await sor.sqlExe(sql, p) + await sor.sqlExe("COMMIT", {}) + if chapter_id is None: + return len(recs or []) + n = 0 + for r in (recs or []): + prm = json_loads(getattr(r, "params", "") or "{}", {}) + if isinstance(prm, dict) and prm.get("chapter_id") == chapter_id: + n += 1 + return n + + +async def has_done_task(sor, project_id, role): + """项目下该角色是否已有验收通过(approved/completed)的任务。""" + recs = await sor.sqlExe( + "SELECT id FROM pipeline_tasks WHERE tenant_id=${pid}$ AND pipeline_id='role_task' " + "AND role=${role}$ AND state IN ('approved','completed') LIMIT 1", + {"pid": project_id, "role": normalize_role(role)}) + await sor.sqlExe("COMMIT", {}) + return bool(recs) + + +async def has_pending_blocking_human_task(sor, project_id): + """项目是否有阻塞中的人类任务(投标产线无迭代,按 project_id 判定)。""" + types = "','".join(BLOCKING_HT_TYPES + (HT_TENDER_APPROVAL,)) + recs = await sor.sqlExe( + "SELECT COUNT(*) AS c FROM pipeline_human_tasks " + "WHERE project_id=${pid}$ AND status='pending' " + "AND task_type IN ('" + types + "')", {"pid": project_id}) + await sor.sqlExe("COMMIT", {}) + return to_int(getattr(recs[0], "c", 0) if recs else 0) > 0 + + +async def create_human_task(sor, project_id, task_type, title, description="", + assignee_role="owner.superuser", assignee_id="", + form_schema=None): + """创建人类任务(pipeline_human_tasks)。iteration_id 留空 → 走项目级门禁。""" + hid = new_id() + await sor.C("pipeline_human_tasks", { + "id": hid, + "task_id": "", + "step_name": task_type, + "version": 1, + "task_type": task_type, + "assignee_role": assignee_role or "owner.superuser", + "assignee_id": assignee_id or "", + "form_schema": json.dumps(form_schema or {}, ensure_ascii=False), + "status": "pending", + "project_id": project_id or "", + "title": (title or "")[:480], + "description": description or "", + }) + logger.info("bid create_human_task type=%s id=%s project=%s", task_type, hid, project_id) + return hid + + +async def find_human_task(sor, project_id, task_type, status=None): + """查项目下指定类型人类任务(最新一条)。""" + sql = ("SELECT * FROM pipeline_human_tasks WHERE project_id=${pid}$ " + "AND task_type=${tt}$") + p = {"pid": project_id, "tt": task_type} + if status: + sql += " AND status=${st}$" + p["st"] = status + sql += " ORDER BY created_at DESC LIMIT 1" + recs = await sor.sqlExe(sql, p) + await sor.sqlExe("COMMIT", {}) + return rec_to_dict(recs[0]) if recs else {} + + +async def llm_json(prompt, model_name="", retries=1): + """调 LLM 并解析 JSON(剥离 markdown code fence)。失败返回 (None, err)。""" + from pipeline_service.llm_bridge import llm_call + last_err = "" + for _ in range(max(1, retries + 1)): + try: + raw = await llm_call(prompt, model=model_name or None) + txt = (raw or "").strip() + if txt.startswith("```"): + parts = txt.split("\n", 1) + txt = parts[1] if len(parts) > 1 else txt + if "```" in txt: + txt = txt.rsplit("```", 1)[0] + txt = txt.strip() + # 容错:截取第一个 { 到最后一个 } + if not txt.startswith("{") and "{" in txt: + txt = txt[txt.find("{"):txt.rfind("}") + 1] + return json.loads(txt), "" + except Exception as e: + last_err = str(e)[:200] + return None, last_err or "LLM 返回无法解析为 JSON" + + +async def record(sor, project_id, entity, entity_id, action, + from_state=None, to_state=None, who=None, agent_id=None, detail=None): + """写审计(复用引擎 audit,失败不影响主流程)。""" + try: + from pipeline_service.audit import record_audit + await record_audit(project_id, entity, entity_id, action, + from_state=from_state, to_state=to_state, + who=normalize_role(who), agent_id=agent_id, + detail=detail, sor=sor) + except Exception as e: + logger.warning("bid audit failed: %s", str(e)[:120]) diff --git a/pipeline_bidding/bid_compose_capability.py b/pipeline_bidding/bid_compose_capability.py new file mode 100644 index 0000000..27b5667 --- /dev/null +++ b/pipeline_bidding/bid_compose_capability.py @@ -0,0 +1,221 @@ +"""标书合成能力(agent.bid_compositor):把已通过的章节合成完整标书(md + docx)。 + +对应业务:最后合成标书。 +产出落 bid_documents(version 递增)+ 工作空间文件,并登记为交付件。 +docx 生成用 python-docx;不可用时降级为 md(诚实降级,不假装成功)。 +""" + +import json +import logging +import os + +from .bid_common import ( + get_db, new_id, rec_to_dict, rows_to_dicts, to_float, to_int, record, + get_project, get_tender_by_project, resolve_project_id, CH_APPROVED, DOC_DRAFT, +) + +logger = logging.getLogger("pipeline.bidding.compose") + + +async def _workspace_dir(sor, project_id): + """项目工作目录:复用引擎 build_workspace_path({base}/{org}/{pipeline_id}/{项目名})。 + + 不走 get_workspace_dir(那个依赖会话当前项目),角色 agent 场景下按项目自身字段解析。 + """ + try: + from pipeline_service.workspace import get_workspace_base, build_workspace_path + base = await get_workspace_base(sor) + recs = await sor.sqlExe( + "SELECT name, org_id, pipeline_id FROM sd_projects WHERE id=${p}$", {"p": project_id}) + await sor.sqlExe("COMMIT", {}) + if base and recs: + r = recs[0] + d = build_workspace_path(base, getattr(r, "org_id", "0") or "0", + getattr(r, "pipeline_id", "") or "bidding_general", + getattr(r, "name", "") or project_id, project_id) + os.makedirs(d, exist_ok=True) + return d + except Exception as e: + logger.warning("workspace resolve failed: %s", str(e)[:120]) + d = os.path.join("/tmp", "pipeline_bidding", project_id) + os.makedirs(d, exist_ok=True) + return d + + +def _write_docx(path, title, subtitle, chapters): + """用 python-docx 写标书。返回 (ok, page_hint_or_err)。""" + try: + from docx import Document + from docx.shared import Pt + from docx.enum.text import WD_ALIGN_PARAGRAPH + except ImportError: + return False, "python-docx 未安装" + try: + doc = Document() + # 中文字体(python-docx 对 CJK 需显式设置 eastasia) + style = doc.styles["Normal"] + style.font.name = "宋体" + style.font.size = Pt(12) + try: + from docx.oxml.ns import qn + style.element.rPr.rFonts.set(qn("w:eastAsia"), "宋体") + except Exception: + pass + + p = doc.add_paragraph() + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + r = p.add_run(title) + r.bold = True + r.font.size = Pt(26) + if subtitle: + p2 = doc.add_paragraph() + p2.alignment = WD_ALIGN_PARAGRAPH.CENTER + r2 = p2.add_run(subtitle) + r2.font.size = Pt(14) + doc.add_page_break() + + doc.add_heading("目 录", level=1) + for c in chapters: + doc.add_paragraph("%s %s" % (c.get("chapter_no", ""), c.get("title", ""))) + doc.add_page_break() + + for c in chapters: + doc.add_heading("%s %s" % (c.get("chapter_no", ""), c.get("title", "")), level=1) + for line in (c.get("content") or "").split("\n"): + t = line.rstrip() + if not t: + continue + if t.startswith("### "): + doc.add_heading(t[4:], level=3) + elif t.startswith("## "): + doc.add_heading(t[3:], level=2) + elif t.startswith("# "): + doc.add_heading(t[2:], level=2) + elif t.lstrip().startswith(("- ", "* ")): + doc.add_paragraph(t.lstrip()[2:], style="List Bullet") + else: + doc.add_paragraph(t) + doc.add_page_break() + doc.save(path) + return True, "" + except Exception as e: + return False, "%s: %s" % (type(e).__name__, str(e)[:200]) + + +async def compose_bid(project_id, doc_name="", who=None, agent_id=None, task_id=""): + """合成标书:要求全部章节 approved(否则拒绝,避免拿半成品合成)。""" + try: + project_id = await resolve_project_id(project_id) + except ValueError as e: + return False, str(e) + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + proj = await get_project(sor, project_id) + if not proj: + return False, "项目不存在: %s" % project_id + recs = await sor.sqlExe( + "SELECT id, chapter_no, title, status, content, max_score, review_score " + "FROM bid_chapters WHERE project_id=${pid}$ ORDER BY order_no, chapter_no", + {"pid": project_id}) + await sor.sqlExe("COMMIT", {}) + chapters = rows_to_dicts(recs, limit=500) + if not chapters: + return False, "项目下无章节,无法合成(请先生成章节骨架并编写)" + bad = [c for c in chapters if c.get("status") != CH_APPROVED] + if bad: + names = ", ".join("%s %s(%s)" % (c.get("chapter_no"), c.get("title"), c.get("status")) + for c in bad[:8]) + return False, ("仍有 %d 个章节未评审通过,不可合成:%s%s" + % (len(bad), names, " ..." if len(bad) > 8 else "")) + tender = await get_tender_by_project(sor, project_id) + vrec = await sor.sqlExe( + "SELECT COALESCE(MAX(version),0) AS v FROM bid_documents WHERE project_id=${pid}$", + {"pid": project_id}) + await sor.sqlExe("COMMIT", {}) + ver = to_int(getattr(vrec[0], "v", 0) if vrec else 0) + 1 + + ws = await _workspace_dir(sor, project_id) + title = doc_name or ("%s 投标文件" % (tender.get("title") or proj.get("name") or "项目")) + subtitle = "投标人:(本单位)  招标编号:%s" % (tender.get("tender_no") or "-") + + md_lines = ["# " + title, "", subtitle, "", "## 目录", ""] + for c in chapters: + md_lines.append("- %s %s" % (c.get("chapter_no"), c.get("title"))) + md_lines.append("") + for c in chapters: + md_lines.append("# %s %s" % (c.get("chapter_no"), c.get("title"))) + md_lines.append("") + md_lines.append(c.get("content") or "") + md_lines.append("") + md = "\n".join(md_lines) + md_path = os.path.join(ws, "bid_v%d.md" % ver) + with open(md_path, "w", encoding="utf-8") as f: + f.write(md) + + docx_path = os.path.join(ws, "bid_v%d.docx" % ver) + ok_docx, err = _write_docx(docx_path, title, subtitle, chapters) + file_path = docx_path if ok_docx else md_path + total_chars = sum(len(c.get("content") or "") for c in chapters) + page_est = max(1, total_chars // 900) # 约 900 字/页(中文小四单倍行距) + max_sum = sum(to_float(c.get("max_score"), 0.0) for c in chapters) + + did = new_id() + await sor.C("bid_documents", { + "id": did, "project_id": project_id, "version": ver, + "doc_name": title[:250], "file_path": file_path[:480], + "page_count": page_est, "chapter_count": len(chapters), + "max_score": max_sum or None, "status": DOC_DRAFT, + "task_id": task_id or "", "created_by": who or "agent.bid_compositor", + }) + await sor.sqlExe("COMMIT", {}) + await record(sor, project_id, "bid_documents", did, "compose", + to_state=DOC_DRAFT, who=who, agent_id=agent_id, + detail="v%d %d章 %d字" % (ver, len(chapters), total_chars)) + note = "" if ok_docx else ("(docx 生成失败,已产出 markdown:%s)" % err) + return True, ("标书已合成 v%d:%s\n章节 %d 个,约 %d 字,估算 %d 页,对应满分 %.1f%s\n" + "系统将自动派发整书评分任务。" + % (ver, file_path, len(chapters), total_chars, page_est, max_sum, note)) + + +async def list_bid_documents(project_id, limit=20): + try: + project_id = await resolve_project_id(project_id) + except ValueError as e: + return [] + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + recs = await sor.sqlExe( + "SELECT id, version, doc_name, file_path, page_count, chapter_count, " + "total_score, max_score, status, created_at FROM bid_documents " + "WHERE project_id=${pid}$ ORDER BY version DESC LIMIT " + str(to_int(limit, 20)), + {"pid": project_id}) + await sor.sqlExe("COMMIT", {}) + return rows_to_dicts(recs) + + +async def read_bid_document(document_id, offset=0, length=15000): + """读合成标书正文(分段),供整书评审用。""" + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + recs = await sor.sqlExe( + "SELECT id, project_id, version, file_path FROM bid_documents WHERE id=${did}$", + {"did": document_id}) + await sor.sqlExe("COMMIT", {}) + if not recs: + return "标书不存在: %s" % document_id + d = rec_to_dict(recs[0]) + path = d.get("file_path") or "" + md = os.path.splitext(path)[0] + ".md" + src = md if os.path.isfile(md) else path + if not src or not os.path.isfile(src): + return "标书文件不存在于磁盘: %s" % path + if src.endswith(".docx"): + from .bid_analysis_capability import _extract_file_text + txt = _extract_file_text(src) + else: + with open(src, "r", encoding="utf-8", errors="ignore") as f: + txt = f.read() + o = to_int(offset, 0) + n = to_int(length, 15000) + return ("【标书 v%s】字符 %d-%d / 共 %d\n%s" + % (d.get("version"), o, o + n, len(txt), txt[o:o + n])) diff --git a/pipeline_bidding/bid_flow.py b/pipeline_bidding/bid_flow.py new file mode 100644 index 0000000..68f58e6 --- /dev/null +++ b/pipeline_bidding/bid_flow.py @@ -0,0 +1,355 @@ +"""投标产线任务流转引擎:状态收敛器(reconciler)+ 后台 poller。 + +设计原则(与开发产线一致):**流转归机制,不归 LLM**。 +不依赖 agent 主动"调下一步工具",而是每 15 秒对每个投标项目做一次幂等状态对账: +按「领域状态」(招标文件/评分项/章节/标书)计算应存在哪些角色任务,缺则补建,已有则跳过。 + +因此: +- 章节评审不达标 → 章节置 rejected → 下一轮对账自动派重写任务(章节级循环) +- 整书评分不达标 → 命中章节置 rejected → 自动重写 → 重新评审 → 重新合成 → 重新评分(整书级循环) +- 人类任务 pending(等招标文件 / 等人类资料)→ 对账直接跳过该项目(阻塞门禁) + +RoleSpec.next_role 全部留空:投标产线不用「任务链跳下一角色」,一律由本对账器决定。 +""" + +import asyncio +import json +import logging + +from .bid_common import ( + get_db, rows_to_dicts, rec_to_dict, to_int, to_float, json_loads, + create_role_task, count_open_tasks, has_done_task, + has_pending_blocking_human_task, find_human_task, get_thresholds, + create_human_task, PIPELINE_ID, + CH_PENDING, CH_WRITING, CH_WRITTEN, CH_REJECTED, CH_APPROVED, + DOC_DRAFT, DOC_REVIEWING, DOC_PASSED, DOC_REJECTED, DOC_DELIVERED, + HT_TENDER_FILE, HT_HUMAN_DOCS, HT_DELIVERY_CONFIRM, +) + +logger = logging.getLogger("pipeline.bidding.flow") + +R_ANALYST = "agent.tender_analyst" +R_PREP = "agent.bid_prep" +R_WRITER = "agent.bid_writer" +R_REVIEWER = "agent.bid_reviewer" +R_COMPOSITOR = "agent.bid_compositor" +R_SCORER = "agent.bid_scorer" + +MAX_ANALYST_ATTEMPT = 2 # 解析阶段最多重派次数(防死循环) +MAX_PREP_ATTEMPT = 2 +POLL_INTERVAL = 15 + + +async def _count(sor, sql, p): + recs = await sor.sqlExe(sql, p) + await sor.sqlExe("COMMIT", {}) + return to_int(getattr(recs[0], "c", 0) if recs else 0) + + +async def _done_task_count(sor, project_id, role): + return await _count(sor, + "SELECT COUNT(*) AS c FROM pipeline_tasks WHERE tenant_id=${p}$ " + "AND pipeline_id='role_task' AND role=${r}$ " + "AND state IN ('approved','completed')", + {"p": project_id, "r": role}) + + +async def _auto_close_file_upload_task(sor, project_id): + """招标文件已上传 → 自动完成「等待上传招标文件」人类任务(上传即完成,不必人工再点一次)。""" + ht = await find_human_task(sor, project_id, HT_TENDER_FILE, status="pending") + if not ht: + return False + n = await _count(sor, "SELECT COUNT(*) AS c FROM bid_tender_files WHERE project_id=${p}$", + {"p": project_id}) + if n <= 0: + return False + await sor.sqlExe( + "UPDATE pipeline_human_tasks SET status='done', qc_status='passed', " + "comments='招标文件已上传,系统自动完成', submitted_at=NOW() WHERE id=${h}$", + {"h": ht.get("id")}) + await sor.sqlExe("COMMIT", {}) + logger.info("bid_flow: auto-closed tender_file_upload task project=%s", project_id) + return True + + +async def reconcile_project(sor, project_id, project_name=""): + """对单个投标项目做一次状态对账。返回动作说明列表(幂等,可反复调用)。""" + acts = [] + pname = project_name or project_id + + # ── 0. 招标文件到位则自动关闭上传任务,再判阻塞门禁 ── + await _auto_close_file_upload_task(sor, project_id) + if await has_pending_blocking_human_task(sor, project_id): + return ["blocked: 有待办人类任务(等招标文件/等人类资料/等审批),本轮不派发"] + + th = await get_thresholds(sor) + + n_files = await _count(sor, "SELECT COUNT(*) AS c FROM bid_tender_files " + "WHERE project_id=${p}$", {"p": project_id}) + n_items = await _count(sor, "SELECT COUNT(*) AS c FROM bid_scoring_items " + "WHERE project_id=${p}$", {"p": project_id}) + n_struct = await _count(sor, "SELECT COUNT(*) AS c FROM bid_doc_requirements " + "WHERE project_id=${p}$ AND req_type='structure'", + {"p": project_id}) + n_quals = await _count(sor, "SELECT COUNT(*) AS c FROM bid_qualifications " + "WHERE project_id=${p}$", {"p": project_id}) + n_quals_pending = await _count(sor, "SELECT COUNT(*) AS c FROM bid_qualifications " + "WHERE project_id=${p}$ AND match_status='pending'", + {"p": project_id}) + + chs = await sor.sqlExe( + "SELECT id, chapter_no, title, status, revise_count FROM bid_chapters " + "WHERE project_id=${p}$ ORDER BY order_no, chapter_no", {"p": project_id}) + await sor.sqlExe("COMMIT", {}) + chapters = rows_to_dicts(chs, limit=500) + + docs = await sor.sqlExe( + "SELECT id, version, status FROM bid_documents WHERE project_id=${p}$ " + "ORDER BY version DESC LIMIT 1", {"p": project_id}) + await sor.sqlExe("COMMIT", {}) + doc = rec_to_dict(docs[0]) if docs else {} + + # ── A. 招标文件解析(评分项 / 资质 / 投标文件要求 / 章节骨架)── + need_analysis = n_files > 0 and (n_items == 0 or n_struct == 0 or not chapters) + if need_analysis: + if await count_open_tasks(sor, project_id, role=R_ANALYST) > 0: + return acts + ["waiting: 招标文件解析任务在办"] + attempts = await _done_task_count(sor, project_id, R_ANALYST) + if attempts >= MAX_ANALYST_ATTEMPT: + ht = await find_human_task(sor, project_id, "general", status="pending") + if not ht: + await create_human_task( + sor, project_id, "general", "招标文件解析未产出完整结果,需人工介入", + ("解析任务已完成 %d 次,但仍缺少:%s。\n" + "请人工检查招标文件是否可读(bid_tender_files.content_text)," + "或人工补录评分项/投标文件要求后完成本任务。" + % (attempts, + "、".join(x for x in [ + "评分项" if n_items == 0 else "", + "投标文件章节结构" if n_struct == 0 else "", + "章节骨架" if not chapters else ""] if x))), + assignee_role="owner.superuser") + await sor.sqlExe("COMMIT", {}) + acts.append("escalated: 解析重试超限 → 人工介入") + return acts + tid = await create_role_task( + sor, project_id, R_ANALYST, + "%s 招标文件解析(评分项/资质/投标文件要求/章节骨架)" % pname, + {"stage": "analysis", "task_kind": "bid_analysis"}) + await sor.sqlExe("COMMIT", {}) + return acts + ["created: 解析任务 %s" % tid] + + # ── B. 资料准备(知识库资质匹配 + 人类文件清单)── + prep_done = await has_done_task(sor, project_id, R_PREP) + if n_quals > 0 and not prep_done: + if await count_open_tasks(sor, project_id, role=R_PREP) > 0: + return acts + ["waiting: 资料准备任务在办"] + tid = await create_role_task( + sor, project_id, R_PREP, + "%s 投标资料准备(资质匹配 + 人类文件清单)" % pname, + {"stage": "prep", "task_kind": "bid_prep", "qual_pending": n_quals_pending}) + await sor.sqlExe("COMMIT", {}) + return acts + ["created: 资料准备任务 %s" % tid] + + if not chapters: + return acts + ["idle: 无章节,等待解析产出章节骨架"] + + # ── C. 章节编写(一章一任务;pending/rejected 且无在办任务)── + created_w = 0 + for c in chapters: + if c.get("status") not in (CH_PENDING, CH_REJECTED): + continue + if to_int(c.get("revise_count"), 0) > th["max_revise"]: + continue # 已超重做上限,等人工介入(review 阶段已抛任务) + if await count_open_tasks(sor, project_id, role=R_WRITER, chapter_id=c["id"]) > 0: + continue + stage = "revise" if c.get("status") == CH_REJECTED else "write" + tid = await create_role_task( + sor, project_id, R_WRITER, + "%s 第%s章《%s》%s" % (pname, c.get("chapter_no"), c.get("title"), + "修改重写" if stage == "revise" else "编写"), + {"stage": stage, "task_kind": "bid_write", "chapter_id": c["id"], + "chapter_no": c.get("chapter_no"), "revise_count": c.get("revise_count")}) + created_w += 1 + acts.append("created: 章节编写任务 %s (%s)" % (tid, c.get("chapter_no"))) + if created_w: + await sor.sqlExe("COMMIT", {}) + + # ── D. 章节评审(written 且无在办评审任务)── + created_r = 0 + for c in chapters: + if c.get("status") != CH_WRITTEN: + continue + if await count_open_tasks(sor, project_id, role=R_REVIEWER, chapter_id=c["id"]) > 0: + continue + tid = await create_role_task( + sor, project_id, R_REVIEWER, + "%s 第%s章《%s》评审打分" % (pname, c.get("chapter_no"), c.get("title")), + {"stage": "review", "task_kind": "bid_review", "chapter_id": c["id"], + "chapter_no": c.get("chapter_no")}) + created_r += 1 + acts.append("created: 章节评审任务 %s (%s)" % (tid, c.get("chapter_no"))) + if created_r: + await sor.sqlExe("COMMIT", {}) + + if created_w or created_r: + return acts + + # ── E. 合成标书(全部章节 approved;无标书或最新标书已被打回)── + all_approved = all(c.get("status") == CH_APPROVED for c in chapters) + if all_approved: + if not doc or doc.get("status") == DOC_REJECTED: + if await count_open_tasks(sor, project_id, role=R_COMPOSITOR) > 0: + return acts + ["waiting: 标书合成任务在办"] + tid = await create_role_task( + sor, project_id, R_COMPOSITOR, "%s 合成投标文件(标书)" % pname, + {"stage": "compose", "task_kind": "bid_compose", + "prev_version": doc.get("version") if doc else 0}) + await sor.sqlExe("COMMIT", {}) + return acts + ["created: 标书合成任务 %s" % tid] + + # ── F. 整书评分 ── + if doc.get("status") in (DOC_DRAFT, DOC_REVIEWING): + if await count_open_tasks(sor, project_id, role=R_SCORER) > 0: + return acts + ["waiting: 整书评分任务在办"] + tid = await create_role_task( + sor, project_id, R_SCORER, + "%s 标书整体评审与评分(v%s)" % (pname, doc.get("version")), + {"stage": "score", "task_kind": "bid_score", + "document_id": doc.get("id"), "version": doc.get("version")}) + await sor.sqlExe("COMMIT", {}) + return acts + ["created: 整书评分任务 %s" % tid] + + # ── G. 通过 → 交付确认 → 项目完成 ── + if doc.get("status") == DOC_PASSED: + ht = await find_human_task(sor, project_id, HT_DELIVERY_CONFIRM) + if not ht: + hid = await create_human_task( + sor, project_id, HT_DELIVERY_CONFIRM, "标书评分通过,请确认交付", + "标书已通过评分门限,请人工复核用印/密封/份数/递交后确认交付。") + await sor.sqlExe("COMMIT", {}) + return acts + ["created: 交付确认任务 %s" % hid] + if ht.get("status") == "done": + await sor.sqlExe( + "UPDATE bid_documents SET status=${st}$, updated_at=NOW() WHERE id=${d}$", + {"st": DOC_DELIVERED, "d": doc.get("id")}) + await sor.sqlExe( + "UPDATE sd_projects SET status='completed', updated_at=NOW() " + "WHERE id=${p}$ AND status<>'completed'", {"p": project_id}) + await sor.sqlExe("COMMIT", {}) + return acts + ["completed: 标书已交付,项目完成"] + return acts + ["waiting: 等待人工交付确认"] + return acts or ["idle: 无待办动作"] + + +async def reconcile_all(sor=None): + """对账全部活跃投标项目。返回 {project_id: [actions]}。""" + if sor is not None: + return await _reconcile_all_with(sor) + db, dbname = get_db() + async with db.sqlorContext(dbname) as s: + return await _reconcile_all_with(s) + + +async def _reconcile_all_with(sor): + recs = await sor.sqlExe( + "SELECT id, name FROM sd_projects WHERE pipeline_id=${pl}$ " + "AND status NOT IN ('paused','archived','completed','cancelled') LIMIT 200", + {"pl": PIPELINE_ID}) + await sor.sqlExe("COMMIT", {}) + out = {} + for r in (recs or []): + pid = getattr(r, "id", "") + pname = getattr(r, "name", "") + if not pid: + continue + try: + out[pid] = await reconcile_project(sor, pid, pname) + except Exception as e: + logger.exception("reconcile project %s failed", pid) + out[pid] = ["error: %s" % str(e)[:200]] + return out + + +async def diagnose_bid_project(project_id): + """诊断投标项目:阶段/门禁/章节分布/在办任务/最近评分,一次看清卡在哪。""" + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + prec = await sor.sqlExe( + "SELECT id, name, status, pipeline_id FROM sd_projects WHERE id=${p}$", + {"p": project_id}) + await sor.sqlExe("COMMIT", {}) + if not prec: + return "项目不存在: %s" % project_id + proj = rec_to_dict(prec[0]) + hts = await sor.sqlExe( + "SELECT id, task_type, title, status FROM pipeline_human_tasks " + "WHERE project_id=${p}$ ORDER BY created_at DESC LIMIT 20", {"p": project_id}) + await sor.sqlExe("COMMIT", {}) + tasks = await sor.sqlExe( + "SELECT id, role, title, state, retry_count FROM pipeline_tasks " + "WHERE tenant_id=${p}$ AND pipeline_id='role_task' " + "ORDER BY created_at DESC LIMIT 30", {"p": project_id}) + await sor.sqlExe("COMMIT", {}) + chs = 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", {}) + docs = await sor.sqlExe( + "SELECT version, status, total_score, max_score FROM bid_documents " + "WHERE project_id=${p}$ ORDER BY version DESC LIMIT 5", {"p": project_id}) + await sor.sqlExe("COMMIT", {}) + counts = {} + for tbl in ("bid_tender_files", "bid_scoring_items", "bid_qualifications", + "bid_doc_requirements", "bid_chapters"): + counts[tbl] = await _count( + sor, "SELECT COUNT(*) AS c FROM " + tbl + " WHERE project_id=${p}$", + {"p": project_id}) + acts = await reconcile_project(sor, project_id, proj.get("name", "")) + return json.dumps({ + "project": proj, "counts": counts, + "chapter_status": {getattr(r, "status", ""): to_int(getattr(r, "c", 0)) + for r in (chs or [])}, + "documents": rows_to_dicts(docs), + "human_tasks": rows_to_dicts(hts), + "role_tasks": rows_to_dicts(tasks), + "reconcile_actions": acts, + }, ensure_ascii=False, default=str) + + +# ══════════════ 后台 poller ══════════════ + +def start_poller(): + """注册投标产线对账 poller(ahserver 启动钩子)。 + + on_startup handler 只 spawn 后台任务,绝不内联无限循环(否则阻塞 aiohttp 启动)。 + """ + try: + from ahserver.configuredServer import add_startup + except ImportError: + logger.warning("ahserver.add_startup 不可用,投标 poller 未启动") + return False + + async def _bid_poller(app): + async def _loop(): + db, dbname = get_db() + while True: + try: + async def _once(): + async with db.sqlorContext(dbname) as sor: + res = await _reconcile_all_with(sor) + for pid, acts in (res or {}).items(): + for a in acts: + if a.startswith(("created", "escalated", "completed", "error")): + logger.info("bid_flow %s: %s", pid, a) + # watchdog:每轮最多 60 秒,防连接池/MDL 锁卡死导致 poller 永久停摆 + await asyncio.wait_for(_once(), timeout=60) + except Exception as e: + logger.warning("bid_poller error/timeout: %s", str(e)[:200]) + await asyncio.sleep(POLL_INTERVAL) + + asyncio.create_task(_loop()) + logger.info("bid_flow poller started (interval=%ss)", POLL_INTERVAL) + + add_startup(_bid_poller) + return True diff --git a/pipeline_bidding/bid_kb_capability.py b/pipeline_bidding/bid_kb_capability.py new file mode 100644 index 0000000..c3f1738 --- /dev/null +++ b/pipeline_bidding/bid_kb_capability.py @@ -0,0 +1,263 @@ +"""投标知识库与资质能力:检索公司投标知识库、匹配所需资质、发布人类应提供的文件清单。 + +对应业务:从公司投标知识库中获取招标所需资质文档;发布人类应提供的文件(清单式人类任务,阻塞门禁)。 +""" + +import json +import logging + +from .bid_common import ( + get_db, new_id, rec_to_dict, rows_to_dicts, to_int, to_float, + record, create_human_task, find_human_task, get_project, resolve_project_id, HT_HUMAN_DOCS, +) + +logger = logging.getLogger("pipeline.bidding.kb") + + +# ══════════════ 知识库 ══════════════ + +async def search_bid_kb(query="", doc_type="", org_id="", limit=10): + """检索公司投标知识库(按名称/标签/编号/正文关键词)。返回命中文档摘要列表。""" + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + sql = ("SELECT id, doc_type, doc_name, doc_no, tags, issuer, customer, " + "contract_amount, valid_until, file_path, LEFT(content_text, 300) AS preview " + "FROM bid_kb_docs WHERE status='active'") + p = {} + if org_id: + sql += " AND org_id=${oid}$" + p["oid"] = org_id + if doc_type: + sql += " AND doc_type=${dt}$" + p["dt"] = doc_type + q = (query or "").strip() + if q: + sql += (" AND (doc_name LIKE ${kw}$ OR tags LIKE ${kw}$ OR doc_no LIKE ${kw}$ " + "OR issuer LIKE ${kw}$ OR customer LIKE ${kw}$ OR content_text LIKE ${kw}$)") + p["kw"] = "%" + q + "%" + sql += " ORDER BY doc_type, created_at DESC LIMIT " + str(to_int(limit, 10)) + recs = await sor.sqlExe(sql, p) + await sor.sqlExe("COMMIT", {}) + return rows_to_dicts(recs) + + +async def list_kb_docs(doc_type="", org_id="", limit=100): + """列出知识库文档(不含正文)。""" + return await search_bid_kb(query="", doc_type=doc_type, org_id=org_id, limit=limit) + + +async def kb_doc_detail(kb_doc_id, length=6000): + """取知识库文档正文(供撰写/资质引用)。""" + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + recs = await sor.sqlExe("SELECT * FROM bid_kb_docs WHERE id=${kid}$", {"kid": kb_doc_id}) + await sor.sqlExe("COMMIT", {}) + if not recs: + return "知识库文档不存在: %s" % kb_doc_id + d = rec_to_dict(recs[0]) + d["content_text"] = (d.get("content_text") or "")[:to_int(length, 6000)] + return json.dumps(d, ensure_ascii=False, default=str) + + +async def add_kb_doc(doc_name, doc_type="qualification", doc_no="", tags="", + file_path="", content_text="", issuer="", customer="", + contract_amount="", valid_from="", valid_until="", + org_id="0", who=None, agent_id=None): + """录入知识库文档(资质证书/业绩案例/模板/财务/人员)。""" + if not doc_name: + return False, "缺少 doc_name" + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + kid = new_id() + data = { + "id": kid, "org_id": org_id or "0", + "doc_type": (doc_type or "qualification")[:30], + "doc_name": str(doc_name)[:250], "doc_no": str(doc_no or "")[:60], + "tags": str(tags or "")[:480], "file_path": str(file_path or "")[:480], + "content_text": content_text or "", "issuer": str(issuer or "")[:250], + "customer": str(customer or "")[:250], "status": "active", + "created_by": who or "", + } + amt = to_float(contract_amount, 0.0) + if amt > 0: + data["contract_amount"] = amt + if valid_from: + data["valid_from"] = str(valid_from)[:10] + if valid_until: + data["valid_until"] = str(valid_until)[:10] + await sor.C("bid_kb_docs", data) + await sor.sqlExe("COMMIT", {}) + return True, "知识库文档已录入: %s (%s)" % (doc_name, kid) + + +# ══════════════ 资质匹配 ══════════════ + +async def list_qualifications(project_id, match_status=""): + try: + project_id = await resolve_project_id(project_id) + except ValueError as e: + return [] + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + sql = ("SELECT id, qual_name, is_mandatory, owner, match_status, kb_doc_id, " + "file_path, note, requirement FROM bid_qualifications WHERE project_id=${pid}$") + p = {"pid": project_id} + if match_status: + sql += " AND match_status=${st}$" + p["st"] = match_status + sql += " ORDER BY is_mandatory DESC, created_at" + recs = await sor.sqlExe(sql, p) + await sor.sqlExe("COMMIT", {}) + out = rows_to_dicts(recs) + for r in out: + r["requirement"] = (r.get("requirement") or "")[:400] + return out + + +async def match_qualification(qual_id, kb_doc_id, note="", who=None, agent_id=None): + """把知识库文档挂到资质项上:match_status=matched。""" + if not (qual_id and kb_doc_id): + return False, "缺少 qual_id 或 kb_doc_id" + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + kb = await sor.sqlExe( + "SELECT id, doc_name, file_path, valid_until FROM bid_kb_docs WHERE id=${kid}$", + {"kid": kb_doc_id}) + await sor.sqlExe("COMMIT", {}) + if not kb: + return False, "知识库文档不存在: %s" % kb_doc_id + d = rec_to_dict(kb[0]) + await sor.sqlExe( + "UPDATE bid_qualifications SET kb_doc_id=${kid}$, match_status='matched', " + "owner='kb', file_path=${fp}$, note=${nt}$, updated_at=NOW() WHERE id=${qid}$", + {"kid": kb_doc_id, "fp": (d.get("file_path") or "")[:480], + "nt": (note or ("命中知识库:" + (d.get("doc_name") or "")))[:480], "qid": qual_id}) + rec = await sor.sqlExe( + "SELECT project_id, qual_name, match_status FROM bid_qualifications WHERE id=${qid}$", + {"qid": qual_id}) + await sor.sqlExe("COMMIT", {}) + if not rec: + return False, "资质项不存在: %s" % qual_id + r = rec_to_dict(rec[0]) + if r.get("match_status") != "matched": + return False, "资质匹配写入失败" + await record(sor, r.get("project_id"), "bid_qualifications", qual_id, "match", + to_state="matched", who=who, agent_id=agent_id, + detail="kb_doc=" + kb_doc_id) + return True, "资质「%s」已匹配知识库文档「%s」" % (r.get("qual_name"), d.get("doc_name")) + + +async def mark_qualification_missing(qual_id, note="", need_human="1", + who=None, agent_id=None): + """知识库无此资质:标记 missing 或 human_required(需人类提供)。""" + st = "human_required" if str(need_human) in ("1", "true", "True", "是") else "missing" + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + await sor.sqlExe( + "UPDATE bid_qualifications SET match_status=${st}$, owner='human', " + "note=${nt}$, updated_at=NOW() WHERE id=${qid}$", + {"st": st, "nt": (note or "")[:480], "qid": qual_id}) + rec = await sor.sqlExe( + "SELECT project_id, qual_name FROM bid_qualifications WHERE id=${qid}$", + {"qid": qual_id}) + await sor.sqlExe("COMMIT", {}) + if not rec: + return False, "资质项不存在: %s" % qual_id + r = rec_to_dict(rec[0]) + await record(sor, r.get("project_id"), "bid_qualifications", qual_id, "mark_missing", + to_state=st, who=who, agent_id=agent_id, detail=note) + return True, "资质「%s」标记为 %s" % (r.get("qual_name"), st) + + +async def request_human_docs(project_id, items="", note="", who=None, agent_id=None): + """发布「人类应提供的文件」清单人类任务(阻塞门禁)。 + + items 为空时自动汇总 match_status in (missing, human_required) 的资质项。 + """ + try: + project_id = await resolve_project_id(project_id) + except ValueError as e: + return False, str(e) + 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_HUMAN_DOCS, status="pending") + if exist: + return True, "已有待办的人类文件清单任务: %s" % exist.get("id") + lines = [] + if items: + if isinstance(items, str): + try: + parsed = json.loads(items) + except (ValueError, TypeError): + parsed = [x.strip() for x in items.split(",") if x.strip()] + else: + parsed = items + for x in (parsed or []): + if isinstance(x, dict): + lines.append("- %s:%s" % (x.get("name", ""), x.get("note", ""))) + else: + lines.append("- %s" % x) + else: + recs = await sor.sqlExe( + "SELECT qual_name, requirement, is_mandatory, note FROM bid_qualifications " + "WHERE project_id=${pid}$ AND match_status IN ('missing','human_required') " + "ORDER BY is_mandatory DESC", {"pid": project_id}) + await sor.sqlExe("COMMIT", {}) + for r in (recs or []): + d = rec_to_dict(r) + lines.append("- %s%s:%s" + % (d.get("qual_name", ""), + "(必需)" if str(d.get("is_mandatory")) == "1" else "", + (d.get("note") or d.get("requirement") or "")[:200])) + if not lines: + return True, "无需人类提供的文件(资质均已从知识库匹配)" + desc = ("以下资质/证明文件公司知识库中缺失,请人工提供原件扫描件并上传到项目工作空间:" + "\n" + "\n".join(lines)) + if note: + desc += "\n\n补充说明:" + note + desc += "\n\n提供完毕后请完成本任务,系统将自动继续投标文件编写。" + hid = await create_human_task( + sor, project_id, HT_HUMAN_DOCS, + "需人工提供的投标资质文件(%d 项)" % len(lines), desc, + assignee_role="owner.superuser", + form_schema={"project_id": project_id, "count": len(lines)}) + await sor.sqlExe( + "UPDATE bid_qualifications SET human_task_id=${hid}$ WHERE project_id=${pid}$ " + "AND match_status IN ('missing','human_required')", {"hid": hid, "pid": project_id}) + await sor.sqlExe("COMMIT", {}) + return True, "已发布人类文件清单任务 %s(%d 项)" % (hid, len(lines)) + + +async def auto_match_qualifications(project_id, org_id="", who=None, agent_id=None): + """批量自动匹配:对 pending 资质项按名称关键词检索知识库,命中即 matched,否则 human_required。""" + try: + project_id = await resolve_project_id(project_id) + except ValueError as e: + return False, str(e) + quals = await list_qualifications(project_id, match_status="pending") + if not quals: + return True, "无待匹配资质项" + matched, missing = 0, 0 + for q in quals: + name = (q.get("qual_name") or "").strip() + if not name: + continue + hits = await search_bid_kb(query=name, org_id=org_id, limit=3) + if not hits: + key = name.replace("证书", "").replace("认证", "").strip()[:8] + if key: + hits = await search_bid_kb(query=key, org_id=org_id, limit=3) + if hits: + ok, _ = await match_qualification(q["id"], hits[0]["id"], + note="自动匹配:" + (hits[0].get("doc_name") or ""), + who=who, agent_id=agent_id) + matched += 1 if ok else 0 + else: + await mark_qualification_missing(q["id"], note="知识库未命中,需人工提供", + need_human="1", who=who, agent_id=agent_id) + missing += 1 + return True, ("资质自动匹配完成:命中知识库 %d 项,需人工提供 %d 项。" + "请对需人工提供的项调用 request_human_docs 发布清单。" % (matched, missing)) diff --git a/pipeline_bidding/bid_mail.py b/pipeline_bidding/bid_mail.py new file mode 100644 index 0000000..92a26a3 --- /dev/null +++ b/pipeline_bidding/bid_mail.py @@ -0,0 +1,307 @@ +"""招标邮件采集:IMAP 拉取 → 解析 → 落库 bid_tenders。 + +凭据来自 bid_mail_accounts 表(password 用 appPublic.aes AES 加密存储,明文不入库、不写死代码)。 +阿里云企业邮:imap.qiye.aliyun.com:993 (SSL)。若开启「安全登录」,password 需填客户端专用密码。 + +IMAP 是阻塞库 → 统一放线程池执行(run_in_executor),不阻塞事件循环。 +""" + +import asyncio +import email +import email.header +import email.utils +import imaplib +import json +import logging +import re +from datetime import datetime, timedelta + +from .bid_common import (get_db, new_id, rec_to_dict, to_int, TD_NEW) + +logger = logging.getLogger("pipeline.bidding.mail") + +# 默认招标关键词(bid_mail_accounts.filter_keywords 未配置时使用) +DEFAULT_KEYWORDS = ["招标", "投标", "采购", "询价", "竞争性磋商", "邀标", "标书", "中标", "资格预审"] + +MAX_BODY = 60000 # 单封邮件正文入库上限 +MAX_MAILS = 200 # 单次最多处理邮件数 + + +def _decode_header(raw): + """MIME 邮件头解码(中文主题/发件人)。""" + if not raw: + return "" + try: + parts = email.header.decode_header(raw) + out = [] + for txt, enc in parts: + if isinstance(txt, bytes): + out.append(txt.decode(enc or "utf-8", errors="ignore")) + else: + out.append(txt) + return "".join(out).strip() + except Exception: + return str(raw) + + +def _body_text(msg): + """提取邮件正文纯文本(优先 text/plain,回退 text/html 去标签)。""" + plain, html = "", "" + if msg.is_multipart(): + for part in msg.walk(): + ctype = part.get_content_type() + disp = str(part.get("Content-Disposition") or "") + if "attachment" in disp: + continue + if ctype not in ("text/plain", "text/html"): + continue + try: + payload = part.get_payload(decode=True) or b"" + charset = part.get_content_charset() or "utf-8" + txt = payload.decode(charset, errors="ignore") + except Exception: + continue + if ctype == "text/plain": + plain += txt + "\n" + else: + html += txt + "\n" + else: + try: + payload = msg.get_payload(decode=True) or b"" + charset = msg.get_content_charset() or "utf-8" + txt = payload.decode(charset, errors="ignore") + except Exception: + txt = "" + if (msg.get_content_type() or "") == "text/html": + html = txt + else: + plain = txt + body = plain.strip() + if not body and html: + body = re.sub(r"<(script|style)[^>]*>.*?", " ", html, flags=re.S | re.I) + body = re.sub(r"<[^>]+>", " ", body) + body = re.sub(r" ?", " ", body) + body = re.sub(r"[ \t]+", " ", body) + body = re.sub(r"\n{3,}", "\n\n", body) + return body.strip()[:MAX_BODY] + + +def _attachments(msg): + """附件清单 [{name, size, ctype}](只记录元信息,文件由人工上传到 bid_tender_files)。""" + out = [] + if not msg.is_multipart(): + return out + for part in msg.walk(): + disp = str(part.get("Content-Disposition") or "") + fname = part.get_filename() + if "attachment" not in disp and not fname: + continue + name = _decode_header(fname) if fname else "" + try: + size = len(part.get_payload(decode=True) or b"") + except Exception: + size = 0 + out.append({"name": name, "size": size, "ctype": part.get_content_type()}) + return out + + +def _match_keywords(subject, body, keywords): + text = (subject or "") + "\n" + (body or "") + for k in keywords: + k = (k or "").strip() + if k and k in text: + return k + return "" + + +def _fetch_sync(host, port, use_ssl, username, password, folder, days, keywords): + """同步 IMAP 拉取(在线程池中执行)。返回 (mails, error)。""" + mails = [] + m = None + try: + if use_ssl: + m = imaplib.IMAP4_SSL(host, port) + else: + m = imaplib.IMAP4(host, port) + m.login(username, password) + m.select(folder or "INBOX") + since = (datetime.now() - timedelta(days=max(1, days))).strftime("%d-%b-%Y") + typ, data = m.search(None, "(SINCE %s)" % since) + if typ != "OK": + return [], "IMAP search 失败: %s" % typ + ids = (data[0] or b"").split() + ids = ids[-MAX_MAILS:] + for i in ids: + typ, msg_data = m.fetch(i, "(RFC822)") + if typ != "OK" or not msg_data: + continue + raw = None + for chunk in msg_data: + if isinstance(chunk, tuple) and len(chunk) > 1: + raw = chunk[1] + break + if not raw: + continue + msg = email.message_from_bytes(raw) + subject = _decode_header(msg.get("Subject")) + body = _body_text(msg) + hit = _match_keywords(subject, body, keywords) + if not hit: + continue + recv = "" + try: + dt = email.utils.parsedate_to_datetime(msg.get("Date")) + if dt: + recv = dt.strftime("%Y-%m-%d %H:%M:%S") + except Exception: + recv = "" + mails.append({ + "uid": (msg.get("Message-ID") or "").strip()[:64] or ("seq-" + i.decode()), + "seq": i.decode(), + "from": _decode_header(msg.get("From"))[:250], + "subject": subject[:480], + "received_at": recv, + "body": body, + "attachments": _attachments(msg), + "keyword": hit, + }) + return mails, "" + except imaplib.IMAP4.error as e: + return [], "IMAP 认证/协议错误: %s(阿里云企业邮开启安全登录后需使用客户端专用密码)" % str(e)[:200] + except Exception as e: + return [], "%s: %s" % (type(e).__name__, str(e)[:200]) + finally: + if m is not None: + try: + m.logout() + except Exception: + pass + + +async def get_accounts(sor, org_id="", account_id=""): + """取启用的邮箱配置(解密密码)。""" + sql = "SELECT * FROM bid_mail_accounts WHERE enabled='1'" + p = {} + if account_id: + sql += " AND id=${aid}$" + p["aid"] = account_id + if org_id: + sql += " AND org_id=${oid}$" + p["oid"] = org_id + recs = await sor.sqlExe(sql, p) + await sor.sqlExe("COMMIT", {}) + out = [] + for r in (recs or []): + d = rec_to_dict(r) + d["password_plain"] = _decrypt(d.get("password", "")) + out.append(d) + return out + + +def _decrypt(enc): + """DB 密码解密(AES;明文兼容:解密失败按明文用)。""" + if not enc: + return "" + try: + from appPublic.jsonConfig import getConfig + from appPublic.aes import aes_decode_b64 + key = getConfig().password_key + if key: + v = aes_decode_b64(key, enc) + if v: + return v + except Exception: + pass + return enc + + +def encrypt_password(plain): + """页面/脚本写入密码时调用(AES 加密),解密失败场景保持明文兼容。""" + if not plain: + return "" + try: + from appPublic.jsonConfig import getConfig + from appPublic.aes import aes_encode_b64 + key = getConfig().password_key + if key: + return aes_encode_b64(key, plain) + except Exception: + pass + return plain + + +async def fetch_account_mails(account, days=None): + """按单个邮箱配置拉取邮件(线程池执行 IMAP)。返回 (mails, error)。""" + kws = [k.strip() for k in (account.get("filter_keywords") or "").split(",") if k.strip()] + if not kws: + kws = DEFAULT_KEYWORDS + d = to_int(days if days is not None else account.get("fetch_days"), 7) + loop = asyncio.get_event_loop() + return await loop.run_in_executor( + None, _fetch_sync, + account.get("host") or "imap.qiye.aliyun.com", + to_int(account.get("port"), 993), + str(account.get("use_ssl") or "1") == "1", + account.get("username") or "", + account.get("password_plain") or "", + account.get("folder") or "INBOX", + d, kws) + + +async def fetch_tenders_to_db(org_id="", account_id="", days=None): + """采集入口:拉邮件 → 按 mail_uid 去重 → 落库 bid_tenders(status=new)。 + + 返回 (ok, message)。message 含每个邮箱的新增/跳过数与错误原因(诚实报错,不静默)。 + """ + db, dbname = get_db() + lines = [] + total_new = 0 + async with db.sqlorContext(dbname) as sor: + accounts = await get_accounts(sor, org_id=org_id, account_id=account_id) + if not accounts: + return False, ("未配置启用的招标邮箱(bid_mail_accounts)。" + "请在「招标邮箱配置」页面添加:host=imap.qiye.aliyun.com port=993 " + "username=邮箱地址 password=客户端专用密码") + for acc in accounts: + mails, err = await fetch_account_mails(acc, days=days) + if err: + lines.append("[%s] 采集失败: %s" % (acc.get("account_name") or acc.get("username"), err)) + continue + new_n, skip_n = 0, 0 + for mail in mails: + exist = await sor.sqlExe( + "SELECT id FROM bid_tenders WHERE mail_uid=${uid}$ AND account_id=${aid}$ LIMIT 1", + {"uid": mail["uid"], "aid": acc.get("id") or ""}) + if exist: + skip_n += 1 + continue + data = { + "id": new_id(), + "org_id": acc.get("org_id") or "0", + "source": "mail", + "account_id": acc.get("id") or "", + "mail_uid": mail["uid"], + "mail_from": mail["from"], + "mail_subject": mail["subject"], + "raw_content": mail["body"], + "attach_json": json.dumps(mail["attachments"], ensure_ascii=False), + "title": mail["subject"][:250], + "status": TD_NEW, + "created_by": "agent.tender_scout", + } + if mail.get("received_at"): + data["received_at"] = mail["received_at"] + await sor.C("bid_tenders", data) + new_n += 1 + await sor.sqlExe( + "UPDATE bid_mail_accounts SET last_fetch_at=NOW(), updated_at=NOW() " + "WHERE id=${aid}$", {"aid": acc.get("id") or ""}) + await sor.sqlExe("COMMIT", {}) + total_new += new_n + lines.append("[%s] 命中招标邮件 %d 封,新增 %d,已存在跳过 %d" + % (acc.get("account_name") or acc.get("username"), + len(mails), new_n, skip_n)) + msg = "\n".join(lines) + if total_new == 0 and all("采集失败" in x for x in lines): + return False, msg + return True, msg diff --git a/pipeline_bidding/bid_review_capability.py b/pipeline_bidding/bid_review_capability.py new file mode 100644 index 0000000..c870c9e --- /dev/null +++ b/pipeline_bidding/bid_review_capability.py @@ -0,0 +1,182 @@ +"""章节评审能力(agent.bid_reviewer):对照招标文件要求 + 投标文件要求 + 得分规则逐章打分。 + +对应业务:每章节完成后对照招标文件要求和投标文件要求评审,分数不够给出改进意见重做。 +达标 → approved;不达标 → rejected + 改进意见 + revise_count+1(reconciler 自动派重写任务); +超过重做上限 → 抛人类介入任务,不无限重做。 +""" + +import json +import logging + +from .bid_common import ( + get_db, new_id, rec_to_dict, rows_to_dicts, json_loads, to_float, to_int, + get_thresholds, record, create_human_task, find_human_task, resolve_project_id, + CH_WRITTEN, CH_REVIEWING, CH_APPROVED, CH_REJECTED, +) + +logger = logging.getLogger("pipeline.bidding.review") + +TABLE = "bid_chapters" + + +async def _chapter(sor, chapter_id): + recs = await sor.sqlExe("SELECT * FROM " + TABLE + " WHERE id=${cid}$", {"cid": chapter_id}) + await sor.sqlExe("COMMIT", {}) + return rec_to_dict(recs[0]) if recs else {} + + +async def review_chapter(chapter_id, score, max_score="", comments="", detail="", + who=None, agent_id=None, task_id=""): + """章节评审落库 + 按门限判定通过/退回。 + + score / max_score:按该章节对应评分项的得分规则打分(max_score 省略时取章节 max_score)。 + comments:不通过时必须给出可执行的改进意见(缺失则拒绝,避免"退回但不说怎么改")。 + """ + if not chapter_id: + return False, "缺少 chapter_id" + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + c = await _chapter(sor, chapter_id) + if not c: + return False, "章节不存在: %s" % chapter_id + pid = c.get("project_id") + th = await get_thresholds(sor) + sc = to_float(score, -1.0) + if sc < 0: + return False, "score 必须为数字(0~满分)" + ms = to_float(max_score, 0.0) or to_float(c.get("max_score"), 0.0) + if ms <= 0: + ms = 100.0 # 章节未挂评分项时按百分制评审 + ratio = sc / ms if ms > 0 else 0.0 + passed = ratio >= th["chapter_pass_ratio"] + if not passed and not (comments or "").strip(): + return False, ("评审不通过必须给出改进意见(comments)," + "写清缺哪些评分维度、应补什么内容、对照哪条得分规则") + + # 轮次 = 该章节已有评审数 + 1 + rn = await sor.sqlExe( + "SELECT COUNT(*) AS c FROM bid_reviews WHERE chapter_id=${cid}$ AND scope='chapter'", + {"cid": chapter_id}) + await sor.sqlExe("COMMIT", {}) + rnd = to_int(getattr(rn[0], "c", 0) if rn else 0) + 1 + rid = new_id() + await sor.C("bid_reviews", { + "id": rid, "project_id": pid, "scope": "chapter", "chapter_id": chapter_id, + "round": rnd, "reviewer": (who or "agent.bid_reviewer")[:60], + "review_type": "agent", "score": sc, "max_score": ms, + "pass_ratio": th["chapter_pass_ratio"], "passed": "1" if passed else "0", + "comments": comments or "", "detail": (detail or "")[:60000], + "task_id": task_id or "", + }) + await sor.sqlExe("COMMIT", {}) + + if passed: + await sor.sqlExe( + "UPDATE " + TABLE + " SET status=${st}$, review_score=${sc}$, max_score=${ms}$, " + "review_comment=${cm}$, updated_at=NOW() WHERE id=${cid}$", + {"st": CH_APPROVED, "sc": sc, "ms": ms, + "cm": (comments or "评审通过")[:60000], "cid": chapter_id}) + await sor.sqlExe("COMMIT", {}) + await record(sor, pid, TABLE, chapter_id, "review_pass", + from_state=c.get("status"), to_state=CH_APPROVED, + who=who, agent_id=agent_id, + detail="%.2f/%.2f (%.0f%%)" % (sc, ms, ratio * 100)) + return True, ("章节「%s」评审通过 %.2f/%.2f(%.0f%%,门限 %.0f%%)" + % (c.get("title"), sc, ms, ratio * 100, th["chapter_pass_ratio"] * 100)) + + revise = to_int(c.get("revise_count"), 0) + 1 + await sor.sqlExe( + "UPDATE " + TABLE + " SET status=${st}$, review_score=${sc}$, max_score=${ms}$, " + "review_comment=${cm}$, revise_count=${rc}$, updated_at=NOW() WHERE id=${cid}$", + {"st": CH_REJECTED, "sc": sc, "ms": ms, "cm": comments or "", + "rc": revise, "cid": chapter_id}) + await sor.sqlExe("COMMIT", {}) + await record(sor, pid, TABLE, chapter_id, "review_reject", + from_state=c.get("status"), to_state=CH_REJECTED, + who=who, agent_id=agent_id, + detail="%.2f/%.2f revise=%d" % (sc, ms, revise)) + + if revise > th["max_revise"]: + exist = await find_human_task(sor, pid, "general", status="pending") + title = "章节反复不达标需人工介入:%s %s" % (c.get("chapter_no"), c.get("title")) + if not (exist and exist.get("title") == title): + await create_human_task( + sor, pid, "general", title, + ("章节「%s %s」已重做 %d 次仍未达门限(最近得分 %.2f/%.2f,门限 %.0f%%)。\n" + "最新改进意见:\n%s\n\n" + "请人工判断:补充素材/调整章节范围/降低目标分,处理完成后完成本任务。" + % (c.get("chapter_no"), c.get("title"), revise, sc, ms, + th["chapter_pass_ratio"] * 100, (comments or "")[:1500])), + assignee_role="owner.superuser") + await sor.sqlExe("COMMIT", {}) + return True, ("章节「%s」评审不通过 %.2f/%.2f,已达重做上限 %d 次 → 已抛人工介入任务" + % (c.get("title"), sc, ms, th["max_revise"])) + return True, ("章节「%s」评审不通过 %.2f/%.2f(%.0f%% < 门限 %.0f%%),已退回重写" + "(第 %d 次),系统将自动派发重写任务" + % (c.get("title"), sc, ms, ratio * 100, + th["chapter_pass_ratio"] * 100, revise)) + + +async def approve_chapter(chapter_id, comment="", who=None, agent_id=None): + """人工/评审直接通过章节(written/reviewing/rejected → approved)。""" + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + c = await _chapter(sor, chapter_id) + if not c: + return False, "章节不存在: %s" % chapter_id + await sor.sqlExe( + "UPDATE " + TABLE + " SET status=${st}$, review_comment=${cm}$, updated_at=NOW() " + "WHERE id=${cid}$ AND status IN ('written','reviewing','rejected','writing')", + {"st": CH_APPROVED, "cm": (comment or "人工通过")[:60000], "cid": chapter_id}) + cur = await _chapter(sor, chapter_id) + if cur.get("status") != CH_APPROVED: + return False, "通过失败:当前状态 %s" % cur.get("status") + await record(sor, c.get("project_id"), TABLE, chapter_id, "approve", + from_state=c.get("status"), to_state=CH_APPROVED, + who=who, agent_id=agent_id, detail=comment) + return True, "章节「%s」已通过" % c.get("title") + + +async def reject_chapter(chapter_id, comment="", who=None, agent_id=None): + """退回章节重写(需给改进意见)。""" + if not (comment or "").strip(): + return False, "退回必须给出改进意见(comment)" + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + c = await _chapter(sor, chapter_id) + if not c: + return False, "章节不存在: %s" % chapter_id + revise = to_int(c.get("revise_count"), 0) + 1 + await sor.sqlExe( + "UPDATE " + TABLE + " SET status=${st}$, review_comment=${cm}$, " + "revise_count=${rc}$, updated_at=NOW() WHERE id=${cid}$", + {"st": CH_REJECTED, "cm": comment[:60000], "rc": revise, "cid": chapter_id}) + await sor.sqlExe("COMMIT", {}) + 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) + + +async def list_reviews(project_id, scope="", chapter_id="", limit=50): + """查评审记录(章节级/整书级)。""" + try: + project_id = await resolve_project_id(project_id) + except ValueError as e: + return [] + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + sql = ("SELECT id, scope, chapter_id, document_id, round, reviewer, score, max_score, " + "passed, pass_ratio, LEFT(comments, 500) AS comments, created_at " + "FROM bid_reviews WHERE project_id=${pid}$") + p = {"pid": project_id} + if scope: + sql += " AND scope=${sc}$" + p["sc"] = scope + if chapter_id: + sql += " AND chapter_id=${cid}$" + p["cid"] = chapter_id + sql += " ORDER BY created_at DESC LIMIT " + str(to_int(limit, 50)) + recs = await sor.sqlExe(sql, p) + await sor.sqlExe("COMMIT", {}) + return rows_to_dicts(recs) diff --git a/pipeline_bidding/bid_score_capability.py b/pipeline_bidding/bid_score_capability.py new file mode 100644 index 0000000..cc394e0 --- /dev/null +++ b/pipeline_bidding/bid_score_capability.py @@ -0,0 +1,334 @@ +"""整书评分能力(agent.bid_scorer):按评分项和得分规则对整个标书打分。 + +对应业务:对整个标书评审,并按照评分项和得分规则对标书进行评分;如果不合适,提出改进意见, +按照意见对问题所在章节修改、评审……直到得分通过。 + +机制:add_score_item_result 逐项落分(含 improvement + target_chapter_id)→ finalize_bid_score +汇总判定;不通过则把命中章节置 rejected(reconciler 自动派重写任务),通过则发人类交付确认任务。 +""" + +import json +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, + CH_APPROVED, CH_REJECTED, DOC_DRAFT, DOC_REVIEWING, DOC_PASSED, DOC_REJECTED, + HT_DELIVERY_CONFIRM, +) + +logger = logging.getLogger("pipeline.bidding.score") + + +async def _latest_doc(sor, project_id, document_id=""): + if document_id: + recs = await sor.sqlExe("SELECT * FROM bid_documents WHERE id=${d}$", {"d": document_id}) + else: + recs = await sor.sqlExe( + "SELECT * FROM bid_documents WHERE project_id=${p}$ ORDER BY version DESC LIMIT 1", + {"p": project_id}) + await sor.sqlExe("COMMIT", {}) + return rec_to_dict(recs[0]) if recs else {} + + +async def start_bid_score(project_id, document_id="", who=None, agent_id=None, task_id=""): + """开启一轮整书评分:创建 bid_reviews(scope=whole) 并返回 review_id + 评分项清单。""" + try: + project_id = await resolve_project_id(project_id) + except ValueError as e: + return False, str(e) + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + doc = await _latest_doc(sor, project_id, document_id) + if not doc: + return False, "项目下无合成标书,请先 compose_bid" + th = await get_thresholds(sor) + rn = await sor.sqlExe( + "SELECT COUNT(*) AS c FROM bid_reviews WHERE project_id=${p}$ AND scope='whole'", + {"p": project_id}) + await sor.sqlExe("COMMIT", {}) + rnd = to_int(getattr(rn[0], "c", 0) if rn else 0) + 1 + # 同一标书版本已有未完成评审 → 复用,避免重复开轮 + open_r = await sor.sqlExe( + "SELECT id FROM bid_reviews WHERE project_id=${p}$ AND scope='whole' " + "AND document_id=${d}$ AND score IS NULL ORDER BY created_at DESC LIMIT 1", + {"p": project_id, "d": doc.get("id")}) + await sor.sqlExe("COMMIT", {}) + if open_r: + rid = getattr(open_r[0], "id", "") + else: + rid = new_id() + await sor.C("bid_reviews", { + "id": rid, "project_id": project_id, "scope": "whole", + "document_id": doc.get("id"), "round": rnd, + "reviewer": (who or "agent.bid_scorer")[:60], "review_type": "agent", + "pass_ratio": th["bid_pass_ratio"], "passed": "0", + "max_score": to_float(doc.get("max_score"), 0.0) or None, + "task_id": task_id or "", + }) + await sor.sqlExe( + "UPDATE bid_documents SET status=${st}$, review_id=${r}$, updated_at=NOW() " + "WHERE id=${d}$", {"st": DOC_REVIEWING, "r": rid, "d": doc.get("id")}) + await sor.sqlExe("COMMIT", {}) + items = await sor.sqlExe( + "SELECT s.id, s.item_no, s.item_name, s.max_score, s.is_veto, s.section, " + "s.scoring_rule, s.target_chapter_no FROM bid_scoring_items s " + "WHERE s.project_id=${p}$ ORDER BY s.section, s.order_no", {"p": project_id}) + await sor.sqlExe("COMMIT", {}) + rows = rows_to_dicts(items, limit=300) + for r in rows: + r["scoring_rule"] = (r.get("scoring_rule") or "")[:800] + return True, json.dumps({ + "review_id": rid, "round": rnd, "document_id": doc.get("id"), + "document_version": doc.get("version"), "file_path": doc.get("file_path"), + "pass_ratio": th["bid_pass_ratio"], "max_round": th["max_round"], + "scoring_items": rows, + "note": ("请逐个评分项调用 add_score_item_result 落分(不合格项必须给 improvement 与 " + "target_chapter_no/target_chapter_id),全部落完后调用 finalize_bid_score 汇总。"), + }, ensure_ascii=False, default=str) + + +async def add_score_item_result(review_id, scoring_item_id="", score=0, max_score="", + reason="", improvement="", target_chapter_no="", + target_chapter_id="", item_name="", who=None, agent_id=None): + """逐个评分项落分。不合格项(得分率 < 门限)必须给 improvement + 目标章节。""" + if not review_id: + return False, "缺少 review_id" + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + rv = await sor.sqlExe("SELECT * FROM bid_reviews WHERE id=${r}$", {"r": review_id}) + await sor.sqlExe("COMMIT", {}) + if not rv: + return False, "评审记录不存在: %s" % review_id + review = rec_to_dict(rv[0]) + pid = review.get("project_id") + th = await get_thresholds(sor) + + it = {} + if scoring_item_id: + irec = await sor.sqlExe( + "SELECT * FROM bid_scoring_items WHERE id=${i}$", {"i": scoring_item_id}) + await sor.sqlExe("COMMIT", {}) + it = rec_to_dict(irec[0]) if irec else {} + ms = to_float(max_score, 0.0) or to_float(it.get("max_score"), 0.0) + if ms <= 0: + return False, "缺少 max_score(评分项满分)" + sc = to_float(score, -1.0) + if sc < 0: + return False, "score 必须为数字" + ratio = sc / ms + weak = ratio < th["bid_pass_ratio"] + if weak and not (improvement or "").strip(): + return False, ("该评分项得分率 %.0f%% 低于门限,必须给出 improvement(改进意见)" + "与目标章节 target_chapter_no/target_chapter_id" % (ratio * 100)) + + cid = target_chapter_id or "" + cno = (target_chapter_no or it.get("target_chapter_no") or "").strip() + if not cid and cno: + crec = await sor.sqlExe( + "SELECT id FROM bid_chapters WHERE project_id=${p}$ AND chapter_no=${c}$ LIMIT 1", + {"p": pid, "c": cno}) + await sor.sqlExe("COMMIT", {}) + cid = getattr(crec[0], "id", "") if crec else "" + if not cid and scoring_item_id: + crec = await sor.sqlExe( + "SELECT id FROM bid_chapters WHERE project_id=${p}$ " + "AND scoring_item_ids LIKE ${kw}$ LIMIT 1", + {"p": pid, "kw": "%" + scoring_item_id + "%"}) + await sor.sqlExe("COMMIT", {}) + cid = getattr(crec[0], "id", "") if crec else "" + + exist = await sor.sqlExe( + "SELECT id FROM bid_scores WHERE review_id=${r}$ AND scoring_item_id=${i}$ LIMIT 1", + {"r": review_id, "i": scoring_item_id or ""}) + await sor.sqlExe("COMMIT", {}) + data = { + "project_id": pid, "review_id": review_id, + "scoring_item_id": scoring_item_id or "", + "item_name": (item_name or it.get("item_name") or "")[:250], + "score": sc, "max_score": ms, "reason": reason or "", + "improvement": improvement or "", "target_chapter_id": cid, + } + if exist: + data["id"] = getattr(exist[0], "id", "") + await sor.U("bid_scores", data) + else: + data["id"] = new_id() + await sor.C("bid_scores", data) + await sor.sqlExe("COMMIT", {}) + return True, ("评分项「%s」已落分 %.2f/%.2f%s" + % (data["item_name"], sc, ms, + "(需改进 → 章节 %s)" % (cid or cno or "未映射") if weak else "")) + + +async def finalize_bid_score(review_id, comments="", who=None, agent_id=None): + """汇总评分:判定通过/不通过。 + + 不通过 → 命中章节置 rejected(带改进意见)→ reconciler 自动派重写任务; + 通过 → 标书 passed + 发人类交付确认任务; + 轮次超上限 → 抛人工介入。 + """ + if not review_id: + return False, "缺少 review_id" + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + rv = await sor.sqlExe("SELECT * FROM bid_reviews WHERE id=${r}$", {"r": review_id}) + await sor.sqlExe("COMMIT", {}) + if not rv: + return False, "评审记录不存在: %s" % review_id + review = rec_to_dict(rv[0]) + pid = review.get("project_id") + th = await get_thresholds(sor) + srecs = await sor.sqlExe( + "SELECT * FROM bid_scores WHERE review_id=${r}$", {"r": review_id}) + await sor.sqlExe("COMMIT", {}) + scores = rows_to_dicts(srecs, limit=500) + if not scores: + return False, "本轮尚无评分明细,请先用 add_score_item_result 逐项落分" + + total = sum(to_float(s.get("score"), 0.0) for s in scores) + maxsum = sum(to_float(s.get("max_score"), 0.0) for s in scores) + # 否决项检查:满分项得 0 分且 is_veto=1 视为致命 + veto_ids = await sor.sqlExe( + "SELECT id, item_name FROM bid_scoring_items WHERE project_id=${p}$ AND is_veto='1'", + {"p": pid}) + await sor.sqlExe("COMMIT", {}) + veto_map = {getattr(r, "id", ""): getattr(r, "item_name", "") for r in (veto_ids or [])} + veto_fail = [veto_map[s["scoring_item_id"]] for s in scores + if s.get("scoring_item_id") in veto_map + and to_float(s.get("score"), 0.0) <= 0] + ratio = (total / maxsum) if maxsum > 0 else 0.0 + passed = (ratio >= th["bid_pass_ratio"]) and not veto_fail + + weak = [s for s in scores + if to_float(s.get("max_score"), 0.0) > 0 + and to_float(s.get("score"), 0.0) / to_float(s.get("max_score"), 1.0) < th["bid_pass_ratio"]] + summary_lines = ["总分 %.2f/%.2f(%.1f%%,门限 %.0f%%)" + % (total, maxsum, ratio * 100, th["bid_pass_ratio"] * 100)] + if veto_fail: + summary_lines.append("⛔ 否决项未响应:" + "、".join(veto_fail[:5])) + for s in weak[:20]: + summary_lines.append("- %s %.1f/%.1f → %s" + % (s.get("item_name"), to_float(s.get("score")), + to_float(s.get("max_score")), + (s.get("improvement") or "")[:200])) + summary = "\n".join(summary_lines) + if comments: + summary += "\n\n评审说明:" + comments + + await sor.sqlExe( + "UPDATE bid_reviews SET score=${sc}$, max_score=${ms}$, passed=${ps}$, " + "comments=${cm}$, updated_at=NOW() WHERE id=${r}$", + {"sc": total, "ms": maxsum, "ps": "1" if passed else "0", + "cm": summary[:60000], "r": review_id}) + doc_status = DOC_PASSED if passed else DOC_REJECTED + await sor.sqlExe( + "UPDATE bid_documents SET status=${st}$, total_score=${sc}$, max_score=${ms}$, " + "pass_ratio=${pr}$, updated_at=NOW() WHERE id=${d}$", + {"st": doc_status, "sc": total, "ms": maxsum, "pr": th["bid_pass_ratio"], + "d": review.get("document_id") or ""}) + await sor.sqlExe("COMMIT", {}) + await record(sor, pid, "bid_documents", review.get("document_id") or "", "score", + to_state=doc_status, who=who, agent_id=agent_id, + detail="%.2f/%.2f round=%s" % (total, maxsum, review.get("round"))) + + if passed: + exist = await find_human_task(sor, pid, HT_DELIVERY_CONFIRM, status="pending") + if not exist: + await create_human_task( + sor, pid, HT_DELIVERY_CONFIRM, + "标书评分通过,请确认交付", + ("标书评分已达通过门限。\n%s\n\n" + "请人工复核并确认交付(用印/密封/份数/递交方式按招标文件要求执行)。" + % summary[:3000]), + assignee_role="owner.superuser") + await sor.sqlExe("COMMIT", {}) + return True, "整书评分通过:%s\n已发人工交付确认任务。" % summary + + # 不通过:轮次门禁 + if to_int(review.get("round"), 1) >= th["max_round"]: + await create_human_task( + sor, pid, "general", + "标书评分连续不达标需人工介入(第 %s 轮)" % review.get("round"), + ("整书评分已进行 %s 轮仍未达门限:\n%s\n\n" + "请人工决策:调整评分预期/补充关键素材/重构章节,处理完成后完成本任务。" + % (review.get("round"), summary[:3000])), + assignee_role="owner.superuser") + await sor.sqlExe("COMMIT", {}) + return True, ("整书评分不通过且已达轮次上限 %d → 已抛人工介入。\n%s" + % (th["max_round"], summary)) + + # 把改进意见落到问题章节:approved → rejected(reconciler 会派重写任务) + touched = 0 + for s in weak: + cid = s.get("target_chapter_id") or "" + if not cid: + continue + crec = await sor.sqlExe( + "SELECT id, status, revise_count, title FROM bid_chapters WHERE id=${c}$", + {"c": cid}) + await sor.sqlExe("COMMIT", {}) + if not crec: + continue + c = rec_to_dict(crec[0]) + imp = ("【整书评分改进意见 · 第%s轮】评分项「%s」得 %.1f/%.1f:\n%s" + % (review.get("round"), s.get("item_name"), + to_float(s.get("score")), to_float(s.get("max_score")), + s.get("improvement") or "")) + await sor.sqlExe( + "UPDATE bid_chapters SET status=${st}$, review_comment=${cm}$, " + "revise_count=${rc}$, updated_at=NOW() WHERE id=${c}$", + {"st": CH_REJECTED, "cm": imp[:60000], + "rc": to_int(c.get("revise_count"), 0) + 1, "c": cid}) + await sor.sqlExe("COMMIT", {}) + await record(sor, pid, "bid_chapters", cid, "score_reject", + from_state=c.get("status"), to_state=CH_REJECTED, + who=who, agent_id=agent_id, detail=(s.get("improvement") or "")[:400]) + touched += 1 + if touched == 0 and weak: + await create_human_task( + sor, pid, "general", "整书评分不通过但改进意见未映射到章节", + ("以下评分项不达标,但 target_chapter_id 为空,系统无法自动定位待改章节:\n%s\n\n" + "请人工指定应修改的章节(或补 target_chapter_no 后重新评分)。" % summary[:3000]), + assignee_role="owner.superuser") + await sor.sqlExe("COMMIT", {}) + return True, ("整书评分不通过:%s\n已退回 %d 个问题章节重写,系统将自动派发重写任务。" + % (summary, touched)) + + +async def request_chapter_revise(chapter_id, improvement, who=None, agent_id=None): + """按改进意见直接退回单个章节(评分员定点回退用)。""" + if not (improvement or "").strip(): + return False, "必须给出 improvement(改进意见)" + from .bid_review_capability import reject_chapter + return await reject_chapter(chapter_id, comment=improvement, who=who, agent_id=agent_id) + + +async def bid_score_report(project_id, limit=50): + """评分报告:最近一轮整书评分的总分 + 逐项明细。""" + try: + project_id = await resolve_project_id(project_id) + except ValueError as e: + return "缺少 project_id" + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + rv = await sor.sqlExe( + "SELECT * FROM bid_reviews WHERE project_id=${p}$ AND scope='whole' " + "ORDER BY created_at DESC LIMIT 1", {"p": project_id}) + await sor.sqlExe("COMMIT", {}) + if not rv: + return "尚无整书评分记录" + r = rec_to_dict(rv[0]) + srecs = await sor.sqlExe( + "SELECT item_name, score, max_score, LEFT(reason,300) AS reason, " + "LEFT(improvement,300) AS improvement, target_chapter_id " + "FROM bid_scores WHERE review_id=${r}$ ORDER BY created_at LIMIT " + str(to_int(limit, 50)), + {"r": r.get("id")}) + await sor.sqlExe("COMMIT", {}) + return json.dumps({ + "review_id": r.get("id"), "round": r.get("round"), + "score": r.get("score"), "max_score": r.get("max_score"), + "passed": r.get("passed"), "pass_ratio": r.get("pass_ratio"), + "comments": (r.get("comments") or "")[:3000], + "items": rows_to_dicts(srecs, limit=to_int(limit, 50)), + }, ensure_ascii=False, default=str) diff --git a/pipeline_bidding/bid_tender_capability.py b/pipeline_bidding/bid_tender_capability.py new file mode 100644 index 0000000..22b8089 --- /dev/null +++ b/pipeline_bidding/bid_tender_capability.py @@ -0,0 +1,465 @@ +"""招标信息能力:采集 / 摘要要素抽取 / 审批 / 存档作废 / 立项 / 人员 / 等待招标文件。 + +对应业务:从邮箱获取招标信息 → 内容摘要(投标开始时间/项目周期/招标金额…)→ 走审批流程 +→ 不通过则存档作废;通过则创建项目、设定参与人员、等待上传招标文件。 + +状态机规范见技能 `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}$, comments=${cm}$, " + "submitted_at=NOW() WHERE id=${hid}$ AND status='pending'", + {"st": status, "cm": (comment or "")[:2000], "hid": hid}) + await sor.sqlExe("COMMIT", {}) + + +# ══════════════ 立项 / 人员 / 招标文件 ══════════════ + +async def create_bid_project(tender_id, name="", org_id="", created_by="", + who=None, agent_id=None): + """由招标信息立项:写 sd_projects(pipeline_id=bidding_general) + 发「上传招标文件」人类任务。""" + 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("project_id"): + return True, "项目已存在: %s" % t.get("project_id") + if t.get("status") != TD_APPROVED: + return False, "招标信息未审批通过(当前 %s),不可立项" % t.get("status") + pid = new_id() + pname = (name or t.get("title") or t.get("mail_subject") or "投标项目")[:190] + await sor.C("sd_projects", { + "id": pid, + "name": pname, + "description": (t.get("summary") or "")[:4000], + "project_type": "bidding", + "pipeline_id": PIPELINE_ID, + "status": "in_progress", + "org_id": org_id or t.get("org_id") or "0", + "created_by": created_by or (who or "agent.tender_scout"), + }) + await sor.sqlExe( + "UPDATE " + TABLE + " SET project_id=${pid}$, updated_at=NOW() WHERE id=${tid}$", + {"pid": pid, "tid": tender_id}) + 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="tender_id=" + tender_id) + 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, "tender_id": tender_id}) + 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) diff --git a/pipeline_bidding/bid_write_capability.py b/pipeline_bidding/bid_write_capability.py new file mode 100644 index 0000000..0718b06 --- /dev/null +++ b/pipeline_bidding/bid_write_capability.py @@ -0,0 +1,140 @@ +"""章节编写能力(agent.bid_writer):查章节、看要求、写正文、提交评审。 + +对应业务:按照投标文件要求分章节编写投标文件。 +一章一任务(任务 params.chapter_id),写完置 written 由评审角色接手(reconciler 自动派评审任务)。 +""" + +import json +import logging + +from .bid_common import ( + get_db, rec_to_dict, rows_to_dicts, json_loads, to_int, record, + resolve_project_id, CH_PENDING, CH_WRITING, CH_WRITTEN, CH_REJECTED, CH_APPROVED, +) + +logger = logging.getLogger("pipeline.bidding.write") + +TABLE = "bid_chapters" +MIN_CONTENT_LEN = 200 # 章节正文最小长度门槛(防空交付) + + +async def list_chapters(project_id, status=""): + """列出章节(不含正文,避免上下文爆炸)。""" + try: + project_id = await resolve_project_id(project_id) + except ValueError as e: + return [] + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + sql = ("SELECT id, chapter_no, title, section, status, version, revise_count, " + "word_count, review_score, max_score, assignee_role " + "FROM " + TABLE + " WHERE project_id=${pid}$") + p = {"pid": project_id} + if status: + sql += " AND status=${st}$" + p["st"] = status + sql += " ORDER BY order_no, chapter_no" + recs = await sor.sqlExe(sql, p) + await sor.sqlExe("COMMIT", {}) + return rows_to_dicts(recs) + + +async def chapter_detail(chapter_id, with_content="1", length=12000): + """章节详情:要点、对应评分项及得分规则、上一轮评审改进意见、当前正文。""" + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + recs = await sor.sqlExe("SELECT * FROM " + TABLE + " WHERE id=${cid}$", {"cid": chapter_id}) + await sor.sqlExe("COMMIT", {}) + if not recs: + return "章节不存在: %s" % chapter_id + d = rec_to_dict(recs[0]) + sids = json_loads(d.get("scoring_item_ids"), []) + items = [] + if sids: + inlist = ",".join("'" + str(x).replace("'", "") + "'" for x in sids) + srecs = await sor.sqlExe( + "SELECT item_no, item_name, max_score, is_veto, scoring_rule, source_ref " + "FROM bid_scoring_items WHERE id IN (" + inlist + ")", {}) + await sor.sqlExe("COMMIT", {}) + items = rows_to_dicts(srecs) + reqs = await sor.sqlExe( + "SELECT req_type, requirement, page_limit FROM bid_doc_requirements " + "WHERE project_id=${pid}$ AND (chapter_no=${cno}$ OR req_type IN ('format','seal','copies')) " + "ORDER BY order_no", + {"pid": d.get("project_id"), "cno": d.get("chapter_no")}) + await sor.sqlExe("COMMIT", {}) + out = { + "id": d.get("id"), "chapter_no": d.get("chapter_no"), "title": d.get("title"), + "section": d.get("section"), "status": d.get("status"), "version": d.get("version"), + "revise_count": d.get("revise_count"), "outline": (d.get("outline") or "")[:6000], + "max_score": d.get("max_score"), "review_score": d.get("review_score"), + "review_comment": (d.get("review_comment") or "")[:6000], + "scoring_items": items, + "doc_requirements": rows_to_dicts(reqs), + } + if str(with_content) in ("1", "true", "True"): + out["content"] = (d.get("content") or "")[:to_int(length, 12000)] + return json.dumps(out, ensure_ascii=False, default=str) + + +async def write_chapter(chapter_id, content, summary="", who=None, agent_id=None): + """写入/覆盖章节正文:pending/rejected/writing → writing,版本+1,字数统计。""" + if not chapter_id: + return False, "缺少 chapter_id" + txt = content or "" + if len(txt.strip()) < MIN_CONTENT_LEN: + return False, ("章节正文过短(%d 字符 < %d),拒绝写入。请按招标文件要求与评分规则" + "写到操作层面(量化指标/流程步骤/责任人/表单),不要写空话。" + % (len(txt.strip()), MIN_CONTENT_LEN)) + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + recs = await sor.sqlExe( + "SELECT id, project_id, status, version, title FROM " + TABLE + " WHERE id=${cid}$", + {"cid": chapter_id}) + await sor.sqlExe("COMMIT", {}) + if not recs: + return False, "章节不存在: %s" % chapter_id + d = rec_to_dict(recs[0]) + if d.get("status") == CH_APPROVED: + return False, "章节已评审通过(approved),不可覆盖;如需修改请先由评审/评分角色退回" + ver = to_int(d.get("version"), 1) + await sor.sqlExe( + "UPDATE " + TABLE + " SET content=${c}$, word_count=${w}$, status=${st}$, " + "version=${v}$, updated_at=NOW() WHERE id=${cid}$", + {"c": txt, "w": len(txt), "st": CH_WRITING, "v": ver + 1, "cid": chapter_id}) + await sor.sqlExe("COMMIT", {}) + await record(sor, d.get("project_id"), TABLE, chapter_id, "write", + from_state=d.get("status"), to_state=CH_WRITING, + who=who, agent_id=agent_id, detail=(summary or "")[:480]) + return True, ("章节「%s」正文已保存(%d 字符,v%d)。确认完成后调用 submit_chapter 提交评审。" + % (d.get("title"), len(txt), ver + 1)) + + +async def submit_chapter(chapter_id, note="", who=None, agent_id=None): + """提交章节评审:writing → written(reconciler 会自动派发评审任务)。""" + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + recs = await sor.sqlExe( + "SELECT id, project_id, status, title, content FROM " + TABLE + " WHERE id=${cid}$", + {"cid": chapter_id}) + await sor.sqlExe("COMMIT", {}) + if not recs: + return False, "章节不存在: %s" % chapter_id + d = rec_to_dict(recs[0]) + if len((d.get("content") or "").strip()) < MIN_CONTENT_LEN: + return False, "章节正文为空或过短,请先 write_chapter 写入正文" + if d.get("status") not in (CH_WRITING, CH_PENDING, CH_REJECTED): + return False, "当前状态 %s 不可提交(需 writing/pending/rejected)" % d.get("status") + await sor.sqlExe( + "UPDATE " + TABLE + " SET status=${st}$, updated_at=NOW() WHERE id=${cid}$ " + "AND status=${old}$", + {"st": CH_WRITTEN, "old": d.get("status"), "cid": chapter_id}) + chk = await sor.sqlExe("SELECT status FROM " + TABLE + " WHERE id=${cid}$", {"cid": chapter_id}) + await sor.sqlExe("COMMIT", {}) + cur = getattr(chk[0], "status", "") if chk else "" + if cur != CH_WRITTEN: + return False, "提交失败(CAS):当前状态 %s" % cur + await record(sor, d.get("project_id"), TABLE, chapter_id, "submit", + from_state=d.get("status"), to_state=CH_WRITTEN, + who=who, agent_id=agent_id, detail=(note or "")[:480]) + return True, "章节「%s」已提交评审" % d.get("title") diff --git a/pipeline_bidding/init.py b/pipeline_bidding/init.py new file mode 100644 index 0000000..1a50309 --- /dev/null +++ b/pipeline_bidding/init.py @@ -0,0 +1,58 @@ +"""pipeline-bidding 模块装载入口。 + +宿主应用(pipeline-app)在 init() 中调用 load_pipeline_bidding() 即挂载投标产线: +能力包注册(bid_ability)+ 角色工具 schema 注册(role_tool_schemas)+ 对账器启动(bid_flow)。 +未装本模块的宿主完全零接触(不 import 本包则以上全部不发生)。 + +表结构不在此处建(铁律:运行期不做 schema 变更),建表走部署期: + json2ddl 生成 → build.sh 应用 / 人工执行(见 build.sh)。 +""" + +import logging + +logger = logging.getLogger("pipeline.bidding") + +_loaded = False + + +def load_pipeline_bidding(): + """挂载投标产线(幂等)。""" + global _loaded + if _loaded: + return True + + ok = True + # 1. 角色工具 schema → 引擎分发字典(全路径模块,引擎裸名约定不变) + try: + from .role_tool_schemas import register_role_tools + n = register_role_tools() + logger.info("[pipeline_bidding] role tool schemas: %d", n) + except Exception as e: + logger.warning("[pipeline_bidding] role tool schema 注册失败: %s", str(e)[:200]) + ok = False + + # 2. 主 agent 能力包 + slash 命令(import 即注册,与开发产线同机制) + try: + from . import bid_ability # noqa: F401 + logger.info("[pipeline_bidding] ability registered: bidding_general") + except Exception as e: + logger.warning("[pipeline_bidding] ability 注册失败: %s", str(e)[:200]) + ok = False + + # 3. 状态对账器(角色任务唯一创建者 + 阻塞门禁守门)。 + # PIPELINE_MODE=web 时不注册(与引擎 poller 同款模式开关)。 + import os + mode = (os.environ.get("PIPELINE_MODE", "all") or "all").strip().lower() + if mode in ("all", "worker"): + try: + from .bid_flow import start_poller + start_poller() + except Exception as e: + logger.warning("[pipeline_bidding] poller 启动失败: %s", str(e)[:200]) + ok = False + else: + logger.info("[pipeline_bidding] PIPELINE_MODE=web,poller 不启动") + + _loaded = ok or _loaded + logger.info("[pipeline_bidding] v0.1.0 loaded") + return True diff --git a/pipeline_bidding/role_tool_schemas.py b/pipeline_bidding/role_tool_schemas.py new file mode 100644 index 0000000..2d424cf --- /dev/null +++ b/pipeline_bidding/role_tool_schemas.py @@ -0,0 +1,305 @@ +"""投标产线角色工具 schema —— 注册进引擎 capability_tools.TOOL_SCHEMAS。 + +分工: +- 引擎 pipeline_service.capability_tools 只保留产线无关的分发机制(module 支持裸名/全路径); +- 投标产线的工具 schema(工具名→实现模块→参数)定义在这里,模块加载时注册进引擎字典。 +这样宿主不装投标产线时,引擎字典里不会出现任何投标字符串。 + +注册时机:init.py 的 load_pipeline_bidding() 调用 register_role_tools()(import 即注册)。 +""" + +import logging + +logger = logging.getLogger("pipeline.bidding.tools") + +_M = "pipeline_bidding" + +BID_ROLE_TOOL_SCHEMAS = { + # ── bid_tender_capability:招标信息员 ── + "fetch_tenders": { + "module": f"{_M}.bid_tender_capability", + "description": "从配置的招标邮箱采集招标信息入库(按关键词过滤+去重)", + "params": {"days": "回溯天数(可选)", "account_id": "邮箱配置ID(可选)"}, + "required": [], + }, + "summarize_tender": { + "module": f"{_M}.bid_tender_capability", + "description": "对招标信息做内容摘要与要素抽取(投标开始时间/项目周期/招标金额/保证金/资质要求)并落库", + "params": {"tender_id": "招标信息ID", "model_name": "模型(可选)"}, + "required": ["tender_id"], + }, + "set_tender_fields": { + "module": f"{_M}.bid_tender_capability", + "description": "修正招标信息要素(摘要抽错时纠正)", + "params": {"tender_id": "招标信息ID", "title": "项目名称(可选)", "purchaser": "招标人(可选)", + "bid_start_at": "投标开始时间(可选)", "bid_deadline": "投标截止(可选)", + "project_period": "项目周期(可选)", "budget_amount": "招标金额(可选)", + "summary": "摘要(可选)", "qualification_req": "资质要求(可选)"}, + "required": ["tender_id"], + }, + "submit_tender_approval": { + "module": f"{_M}.bid_tender_capability", + "description": "提交招标信息审批(生成人类审批任务)", + "params": {"tender_id": "招标信息ID", "note": "提交说明(可选)"}, + "required": ["tender_id"], + }, + "list_tenders": { + "module": f"{_M}.bid_tender_capability", + "description": "列出招标信息", + "params": {"status": "状态(可选)", "limit": "条数(可选)"}, + "required": [], + }, + "archive_tender": { + "module": f"{_M}.bid_tender_capability", + "description": "存档作废招标信息", + "params": {"tender_id": "招标信息ID", "reason": "原因"}, + "required": ["tender_id"], + }, + "assign_members": { + "module": f"{_M}.bid_tender_capability", + "description": "设定投标项目参与人员,如「张三:负责人,李四:writer」", + "params": {"project_id": "项目ID(可选,默认当前)", + "members": "人员清单(姓名:角色 逗号分隔,或JSON数组)"}, + "required": ["members"], + }, + "list_members": { + "module": f"{_M}.bid_tender_capability", + "description": "查看投标项目参与人员", + "params": {"project_id": "项目ID(可选,默认当前)"}, + "required": [], + }, + # ── bid_analysis_capability:招标文件分析师 ── + "read_tender_file": { + "module": f"{_M}.bid_analysis_capability", + "description": "分段读取招标文件正文(供分析)", + "params": {"file_id": "招标文件ID", "offset": "起始字符(可选)", "length": "长度(可选)"}, + "required": ["file_id"], + }, + "list_tender_files": { + "module": f"{_M}.bid_analysis_capability", + "description": "列出项目招标文件及抽取状态", + "params": {"project_id": "项目ID(可选,默认当前)", "extract_status": "抽取状态(可选)"}, + "required": [], + }, + "extract_from_file": { + "module": f"{_M}.bid_analysis_capability", + "description": "LLM 一次性抽取评分项+得分规则/所需资质/投标文件要求并批量落库(大文件分段调)", + "params": {"project_id": "项目ID(可选,默认当前)", "file_id": "招标文件ID(可选)", + "offset": "分段起始字符(可选)", "length": "分段长度(可选)"}, + "required": [], + }, + "add_scoring_item": { + "module": f"{_M}.bid_analysis_capability", + "description": "录入评分项及得分规则(抽取漏项时人工补录)", + "params": {"project_id": "项目ID(可选,默认当前)", "item_name": "评分项名称", "max_score": "满分", + "scoring_rule": "得分规则原文", "section": "business|technical|price(可选)", + "is_veto": "是否否决项 0/1(可选)", "target_chapter_no": "对应章节号(可选)"}, + "required": ["item_name", "max_score"], + }, + "add_qualification": { + "module": f"{_M}.bid_analysis_capability", + "description": "录入招标所需资质", + "params": {"project_id": "项目ID(可选,默认当前)", "qual_name": "资质名称", + "requirement": "要求原文(可选)", "is_mandatory": "是否必需 0/1(可选)"}, + "required": ["qual_name"], + }, + "add_doc_requirement": { + "module": f"{_M}.bid_analysis_capability", + "description": "录入投标文件要求(structure 类即章节骨架来源)", + "params": {"project_id": "项目ID(可选,默认当前)", "requirement": "要求内容", + "req_type": "structure|format|seal|copies|deadline|other(可选)", + "chapter_no": "章节号(可选)", "chapter_title": "章节标题(可选)", + "page_limit": "页数限制(可选)"}, + "required": ["requirement"], + }, + "create_chapter_outline": { + "module": f"{_M}.bid_analysis_capability", + "description": "生成章节骨架(默认用 structure 类要求自动生成,并挂评分项)", + "params": {"project_id": "项目ID(可选,默认当前)", "chapters": "章节JSON数组(可选,默认自动生成)"}, + "required": [], + }, + "list_scoring_items": { + "module": f"{_M}.bid_analysis_capability", + "description": "列出评分项与得分规则", + "params": {"project_id": "项目ID(可选,默认当前)", "section": "评分部分(可选)"}, + "required": [], + }, + "list_doc_requirements": { + "module": f"{_M}.bid_analysis_capability", + "description": "列出投标文件要求", + "params": {"project_id": "项目ID(可选,默认当前)", "req_type": "类型(可选)"}, + "required": [], + }, + "mark_file_extracted": { + "module": f"{_M}.bid_analysis_capability", + "description": "标记招标文件抽取完成", + "params": {"file_id": "招标文件ID", "note": "说明(可选)"}, + "required": ["file_id"], + }, + # ── bid_kb_capability:投标资料专员 ── + "search_bid_kb": { + "module": f"{_M}.bid_kb_capability", + "description": "检索公司投标知识库(资质证书/业绩案例/模板/财务/人员)", + "params": {"query": "关键词", "doc_type": "类型(可选)", "org_id": "机构(可选)"}, + "required": [], + }, + "kb_doc_detail": { + "module": f"{_M}.bid_kb_capability", + "description": "取知识库文档正文", + "params": {"kb_doc_id": "知识库文档ID"}, + "required": ["kb_doc_id"], + }, + "list_qualifications": { + "module": f"{_M}.bid_kb_capability", + "description": "列出招标所需资质及匹配状态", + "params": {"project_id": "项目ID(可选,默认当前)", "match_status": "匹配状态(可选)"}, + "required": [], + }, + "match_qualification": { + "module": f"{_M}.bid_kb_capability", + "description": "把知识库文档挂到资质项上(pending→matched)", + "params": {"qual_id": "资质项ID", "kb_doc_id": "知识库文档ID", "note": "说明(可选)"}, + "required": ["qual_id", "kb_doc_id"], + }, + "mark_qualification_missing": { + "module": f"{_M}.bid_kb_capability", + "description": "知识库无此资质:标记 missing 或 human_required", + "params": {"qual_id": "资质项ID", "note": "说明(可选)", "need_human": "需人工提供 0/1(默认1)"}, + "required": ["qual_id"], + }, + "auto_match_qualifications": { + "module": f"{_M}.bid_kb_capability", + "description": "批量自动匹配全部 pending 资质项(命中知识库即 matched,否则 human_required)", + "params": {"project_id": "项目ID(可选,默认当前)"}, + "required": [], + }, + "request_human_docs": { + "module": f"{_M}.bid_kb_capability", + "description": "发布「人类应提供的文件」清单任务(阻塞门禁;默认自动汇总缺失资质)", + "params": {"project_id": "项目ID(可选,默认当前)", "items": "清单(可选)", "note": "说明(可选)"}, + "required": [], + }, + # ── bid_write_capability:标书撰写工程师 ── + "list_chapters": { + "module": f"{_M}.bid_write_capability", + "description": "列出章节及状态", + "params": {"project_id": "项目ID(可选,默认当前)", "status": "状态(可选)"}, + "required": [], + }, + "chapter_detail": { + "module": f"{_M}.bid_write_capability", + "description": "章节详情:要点/对应评分项及得分规则/上轮改进意见/当前正文", + "params": {"chapter_id": "章节ID"}, + "required": ["chapter_id"], + }, + "write_chapter": { + "module": f"{_M}.bid_write_capability", + "description": "写入章节正文(写到操作层面,禁止空话;有最小长度门槛)", + "params": {"chapter_id": "章节ID", "content": "章节正文", "summary": "本次写作要点(可选)"}, + "required": ["chapter_id", "content"], + }, + "submit_chapter": { + "module": f"{_M}.bid_write_capability", + "description": "提交章节评审(written 后系统自动派评审任务)", + "params": {"chapter_id": "章节ID", "note": "说明(可选)"}, + "required": ["chapter_id"], + }, + # ── bid_review_capability:标书评审员 ── + "review_chapter": { + "module": f"{_M}.bid_review_capability", + "description": "章节评审打分:对照评分项得分规则+招标文件/投标文件要求;不达标必须给改进意见", + "params": {"chapter_id": "章节ID", "score": "得分(数字)", "max_score": "满分(可选,默认取章节挂载满分)", + "comments": "评审意见/改进意见(不通过必填)", "detail": "评审明细(可选)"}, + "required": ["chapter_id", "score"], + }, + "approve_chapter": { + "module": f"{_M}.bid_review_capability", + "description": "直接通过章节(特殊情况)", + "params": {"chapter_id": "章节ID", "comment": "意见(可选)"}, + "required": ["chapter_id"], + }, + "reject_chapter": { + "module": f"{_M}.bid_review_capability", + "description": "退回章节重写(必须给改进意见)", + "params": {"chapter_id": "章节ID", "comment": "改进意见"}, + "required": ["chapter_id", "comment"], + }, + "list_reviews": { + "module": f"{_M}.bid_review_capability", + "description": "查评审记录", + "params": {"project_id": "项目ID(可选,默认当前)", "scope": "chapter|whole(可选)", + "chapter_id": "章节ID(可选)"}, + "required": [], + }, + # ── bid_compose_capability:标书合成工程师 ── + "compose_bid": { + "module": f"{_M}.bid_compose_capability", + "description": "合成标书(要求全部章节已评审通过;产出 md+docx 并登记版本)", + "params": {"project_id": "项目ID(可选,默认当前)", "doc_name": "标书名称(可选)"}, + "required": [], + }, + "list_bid_documents": { + "module": f"{_M}.bid_compose_capability", + "description": "列出已合成标书版本及评分", + "params": {"project_id": "项目ID(可选,默认当前)"}, + "required": [], + }, + "read_bid_document": { + "module": f"{_M}.bid_compose_capability", + "description": "分段读取合成标书正文(供整书评审)", + "params": {"document_id": "标书ID", "offset": "起始字符(可选)", "length": "长度(可选)"}, + "required": ["document_id"], + }, + # ── bid_score_capability:标书评分员(模拟评标)── + "start_bid_score": { + "module": f"{_M}.bid_score_capability", + "description": "开启一轮整书评分:返回评审ID+全部评分项清单", + "params": {"project_id": "项目ID(可选,默认当前)", "document_id": "标书ID(可选,默认最新)"}, + "required": [], + }, + "add_score_item_result": { + "module": f"{_M}.bid_score_capability", + "description": "逐个评分项落分;不合格项必须给 improvement+目标章节", + "params": {"review_id": "评审ID", "scoring_item_id": "评分项ID", "score": "得分", + "max_score": "满分(可选)", "reason": "评分理由(可选)", + "improvement": "改进意见(不合格必填)", "target_chapter_no": "待改章节号(可选)", + "target_chapter_id": "待改章节ID(可选)"}, + "required": ["review_id", "scoring_item_id", "score"], + }, + "finalize_bid_score": { + "module": f"{_M}.bid_score_capability", + "description": "汇总整书评分:通过→发交付确认任务;不通过→改进意见落到问题章节自动退回重写", + "params": {"review_id": "评审ID", "comments": "总体评审说明(可选)"}, + "required": ["review_id"], + }, + "request_chapter_revise": { + "module": f"{_M}.bid_score_capability", + "description": "按改进意见定点退回单个章节", + "params": {"chapter_id": "章节ID", "improvement": "改进意见"}, + "required": ["chapter_id", "improvement"], + }, + "bid_score_report": { + "module": f"{_M}.bid_score_capability", + "description": "最近一轮整书评分报告(总分+逐项明细)", + "params": {"project_id": "项目ID(可选,默认当前)"}, + "required": [], + }, +} + + +def register_role_tools(): + """把投标角色工具 schema 注册进引擎分发字典(幂等)。 + + 引擎不可用(未装 pipeline-service)时诚实降级返回 0,不抛异常。 + """ + try: + from pipeline_service.capability_tools import TOOL_SCHEMAS + except Exception as e: + logger.warning("引擎不可用,投标角色工具未注册: %s", str(e)[:160]) + return 0 + added = 0 + for name, schema in BID_ROLE_TOOL_SCHEMAS.items(): + TOOL_SCHEMAS[name] = schema + added += 1 + logger.info("bid role tool schemas registered: %d", added) + return added diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0c90dcd --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "pipeline_bidding" +version = "0.1.0" +description = "投标产线:招标信息采集→审批→立项→解析→资质准备→分章节编写→评审→合成→评分→交付" +dependencies = [] + +[tool.setuptools.packages.find] +where = ["."] +include = ["pipeline_bidding*"] diff --git a/scripts/gen_ddl.sh b/scripts/gen_ddl.sh new file mode 100644 index 0000000..5bd244f --- /dev/null +++ b/scripts/gen_ddl.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# 生成 pipeline-bidding 的 MySQL DDL(json2ddl 需要 sqlor + appPublic 在 PYTHONPATH) +set -e +R=/home/ymq/work/repos +export PYTHONPATH="$R/xls2ddl:$R/sqlor:$R/apppublic" +python3 "$R/xls2ddl/gen_bidding_ddl.py" diff --git a/scripts/load_path.py b/scripts/load_path.py new file mode 100644 index 0000000..b0a6688 --- /dev/null +++ b/scripts/load_path.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""RBAC path registration for pipeline-bidding module. + +与平台惯例一致:静态资源 → any(免登录),页面/CRUD/API → logined。 +由宿主应用根目录执行:py3/bin/python pkgs/pipeline-bidding/scripts/load_path.py +""" +import subprocess +import sys + +MOD = "pipeline-bidding" + +TABLES = [ + "bid_mail_accounts", "bid_tenders", "bid_members", "bid_tender_files", + "bid_scoring_items", "bid_qualifications", "bid_doc_requirements", + "bid_chapters", "bid_reviews", "bid_scores", "bid_kb_docs", "bid_documents", +] + +PATHS_ANY = [ + "/imgs/node-expand.svg", + "/imgs/node-collapse.svg", + "/imgs/open-folder.svg", + "/imgs/close-folder.svg", +] + +PATHS_LOGINED = [ + "/%s" % MOD, + "/%s/index.ui" % MOD, +] +for t in TABLES: + PATHS_LOGINED += [ + "/%s/%s/index.ui" % (MOD, t), + "/%s/%s/get_%s.dspy" % (MOD, t, t), + "/%s/%s/add_%s.dspy" % (MOD, t, t), + "/%s/%s/update_%s.dspy" % (MOD, t, t), + "/%s/%s/delete_%s.dspy" % (MOD, t, t), + ] +# 产线业务 API(当前无自写 dspy;保留结构,后续新增在此登记) +PATHS_LOGINED += [ + "/%s/api/" % MOD, +] + + +def _run(role, path): + r = subprocess.run([sys.executable, "set_role_perm.py", role, path], + capture_output=True, text=True) + if r.returncode != 0: + print(" FAIL [%s] %s: %s" % (role, path, (r.stderr or "").strip()[:120])) + return r.returncode == 0 + + +def main(): + print("=== %s RBAC registration ===" % MOD) + n = 0 + for p in PATHS_ANY: + n += _run("any", p) + print(" any: %s" % p) + for p in PATHS_LOGINED: + n += _run("logined", p) + print(" logined: %s" % p) + print("Done. %d/%d paths registered" % (n, len(PATHS_ANY) + len(PATHS_LOGINED))) + + +if __name__ == "__main__": + main() diff --git a/wwwroot/bid_chapters/add_bid_chapters.dspy b/wwwroot/bid_chapters/add_bid_chapters.dspy new file mode 100644 index 0000000..56b338a --- /dev/null +++ b/wwwroot/bid_chapters/add_bid_chapters.dspy @@ -0,0 +1,41 @@ + +ns = params_kw.copy() +for k,v in ns.items(): + if v == 'NaN' or v == 'null': + ns[k] = None +id = params_kw.id +if not id or len(id) > 32: + id = uuid() +ns['id'] = id + + + + +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + r = await sor.C('bid_chapters', ns.copy()) + _new_rows = await sor.sqlExe("SELECT * FROM bid_chapters WHERE id = ${id}$", {'id': id}) + _user_data = _new_rows[0] if _new_rows else ns + return { + "widgettype":"Message", + "options":{ + "cwidth":16, + "cheight":9, + "title":"Add Success", + "timeout":3, + "message":"ok", + "user_data": _user_data + } + } + +return { + "widgettype":"Error", + "options":{ + "title":"Add Error", + "cwidth":16, + "cheight":9, + "timeout":3, + "message":"failed" + } +} \ No newline at end of file diff --git a/wwwroot/bid_chapters/delete_bid_chapters.dspy b/wwwroot/bid_chapters/delete_bid_chapters.dspy new file mode 100644 index 0000000..5457889 --- /dev/null +++ b/wwwroot/bid_chapters/delete_bid_chapters.dspy @@ -0,0 +1,33 @@ + +ns = { + 'id':params_kw['id'], +} + + +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + r = await sor.D('bid_chapters', ns) + debug('delete success'); + return { + "widgettype":"Message", + "options":{ + "title":"Delete Success", + "timeout":3, + "cwidth":16, + "cheight":9, + "message":"ok" + } + } + +debug('Delete failed'); +return { + "widgettype":"Error", + "options":{ + "title":"Delete Error", + "timeout":3, + "cwidth":16, + "cheight":9, + "message":"failed" + } +} \ No newline at end of file diff --git a/wwwroot/bid_chapters/get_bid_chapters.dspy b/wwwroot/bid_chapters/get_bid_chapters.dspy new file mode 100644 index 0000000..c56135c --- /dev/null +++ b/wwwroot/bid_chapters/get_bid_chapters.dspy @@ -0,0 +1,50 @@ + +ns = params_kw.copy() + + +debug(f'get_bid_chapters.dspy:{ns=}') +if not ns.get('page'): + ns['page'] = 1 +if not ns.get('sort'): + + + ns['sort'] = json.loads('["order_no", "chapter_no"]') + + + +sql = '''select a.*, b.status_text, c.section_text +from (select * from bid_chapters where 1=1 [[filterstr]]) a left join (select k as status, + v as status_text from appcodes_kv where parentid='bid_chapter_status') b on a.status = b.status left join (select k as section, + v as section_text from appcodes_kv where parentid='bid_scoring_section') c on a.section = c.section''' + + +filterjson = json.loads('{"AND": [{"field": "project_id", "op": "=", "var": "project_id"}, {"field": "status", "op": "=", "var": "status_input"}, {"field": "title", "op": "LIKE", "var": "title_input"}]}') + + +filterdic = ns.copy() +filterdic['filterstr'] = '' +filterdic['userorgid'] = '${userorgid}$' +filterdic['userid'] = '${userid}$' +if filterjson: + dbf = DBFilter(filterjson) + conds = dbf.gen(ns) + if conds: + ns.update(dbf.consts) + conds = f' and {conds}' + filterdic['filterstr'] = conds +ac = ArgsConvert('[[', ']]') +vars = ac.findAllVariables(sql) +NameSpace = {v:'${' + v + '}$' for v in vars if v != 'filterstr' } +filterdic.update(NameSpace) +sql = ac.convert(sql, filterdic) + +debug(f'{sql=}') +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + r = await sor.sqlPaging(sql, ns) + return r +return { + "total":0, + "rows":[] +} \ No newline at end of file diff --git a/wwwroot/bid_chapters/index.ui b/wwwroot/bid_chapters/index.ui new file mode 100644 index 0000000..9b7c5a5 --- /dev/null +++ b/wwwroot/bid_chapters/index.ui @@ -0,0 +1,370 @@ + +{ + "widgettype":"VBox", + "options":{"height":"100%","width":"100%"}, + "subwidgets":[ +{ + "id":"bid_chapters_tbl", + "widgettype":"Tabular", + "options":{ + "width":"100%", + "height":"100%", + + + "title":"投标文件章节", + + + + + "toolbar":{ + "tools": [] +}, + + "css":"card", + + + "editable":{ + + "new_data_url": "{{entire_url('add_bid_chapters.dspy')}}", + + + "delete_data_url": "{{entire_url('delete_bid_chapters.dspy')}}", + + + "update_data_url": "{{entire_url('update_bid_chapters.dspy')}}" + + + }, + + + "data_url":"{{entire_url('./get_bid_chapters.dspy')}}", + + "data_method":"GET", + "data_params":{{json.dumps(params_kw, indent=4, ensure_ascii=False)}}, + "row_options":{ + + + + "browserfields": { + "exclouded": [ + "content", + "outline", + "scoring_item_ids" + ] +}, + + + "editexclouded":[ + "id", + "created_at", + "updated_at", + "task_id", + "version", + "word_count" +], + + "fields":[ + { + "name": "id", + "title": "主键ID", + "type": "str", + "length": 32, + "nullable": "no", + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "主键ID" + }, + { + "name": "project_id", + "title": "项目ID", + "type": "str", + "length": 32, + "nullable": "no", + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "项目ID" + }, + { + "name": "chapter_no", + "title": "章节号", + "type": "str", + "length": 32, + "nullable": "no", + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "章节号" + }, + { + "name": "parent_no", + "title": "父章节号", + "type": "str", + "length": 32, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "父章节号" + }, + { + "name": "title", + "title": "章节标题", + "type": "str", + "length": 255, + "nullable": "no", + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "章节标题" + }, + { + "name": "section", + "title": "所属部分", + "type": "str", + "length": 32, + "default": "technical", + "label": "所属部分", + "uitype": "code", + "valueField": "section", + "textField": "section_text", + "params": { + "dbname": "{{get_module_dbname('pipeline_bidding')}}", + "table": "appcodes_kv", + "tblvalue": "k", + "tbltext": "v", + "valueField": "section", + "textField": "section_text", + "cond": "parentid='bid_scoring_section'" + }, + "dataurl": "{{entire_url('/appbase/get_code.dspy')}}" + }, + { + "name": "scoring_item_ids", + "title": "对应评分项(JSON)", + "type": "text", + "length": 0, + "uitype": "text", + "datatype": "text", + "label": "对应评分项(JSON)" + }, + { + "name": "outline", + "title": "章节要点", + "type": "text", + "length": 0, + "uitype": "text", + "datatype": "text", + "label": "章节要点" + }, + { + "name": "status", + "title": "章节状态", + "type": "str", + "length": 16, + "nullable": "no", + "default": "pending", + "label": "章节状态", + "uitype": "code", + "valueField": "status", + "textField": "status_text", + "params": { + "dbname": "{{get_module_dbname('pipeline_bidding')}}", + "table": "appcodes_kv", + "tblvalue": "k", + "tbltext": "v", + "valueField": "status", + "textField": "status_text", + "cond": "parentid='bid_chapter_status'" + }, + "dataurl": "{{entire_url('/appbase/get_code.dspy')}}" + }, + { + "name": "version", + "title": "版本号", + "type": "int", + "nullable": "no", + "default": "1", + "length": 0, + "uitype": "int", + "datatype": "int", + "label": "版本号" + }, + { + "name": "content", + "title": "章节正文", + "type": "text", + "length": 0, + "uitype": "text", + "datatype": "text", + "label": "章节正文" + }, + { + "name": "file_path", + "title": "章节文件路径", + "type": "str", + "length": 500, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "章节文件路径" + }, + { + "name": "word_count", + "title": "字数", + "type": "int", + "default": "0", + "length": 0, + "uitype": "int", + "datatype": "int", + "label": "字数" + }, + { + "name": "review_score", + "title": "评审得分", + "type": "float", + "length": 8, + "dec": 2, + "cwidth": 8, + "uitype": "float", + "datatype": "float", + "label": "评审得分" + }, + { + "name": "max_score", + "title": "对应满分", + "type": "float", + "length": 8, + "dec": 2, + "cwidth": 8, + "uitype": "float", + "datatype": "float", + "label": "对应满分" + }, + { + "name": "review_comment", + "title": "评审意见/改进意见", + "type": "text", + "length": 0, + "uitype": "text", + "datatype": "text", + "label": "评审意见/改进意见" + }, + { + "name": "revise_count", + "title": "重做次数", + "type": "int", + "nullable": "no", + "default": "0", + "length": 0, + "uitype": "int", + "datatype": "int", + "label": "重做次数" + }, + { + "name": "assignee_role", + "title": "承办角色", + "type": "str", + "length": 64, + "default": "agent.bid_writer", + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "承办角色" + }, + { + "name": "task_id", + "title": "关联任务ID", + "type": "str", + "length": 32, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "关联任务ID" + }, + { + "name": "order_no", + "title": "排序", + "type": "int", + "default": "0", + "length": 0, + "uitype": "int", + "datatype": "int", + "label": "排序" + }, + { + "name": "created_at", + "title": "创建时间", + "type": "timestamp", + "nullable": "no", + "length": 0, + "uitype": "str", + "datatype": "timestamp", + "label": "创建时间" + }, + { + "name": "updated_at", + "title": "更新时间", + "type": "timestamp", + "length": 0, + "uitype": "str", + "datatype": "timestamp", + "label": "更新时间" + } +] + }, + + + + "search_form":{ + "css":"card", + "padding":"8px", + "show_label":false, + "submit_label":"搜索", + "submit_css":"primary", + "fields":[ + { + "name": "project_id", + "uitype": "str", + "placeholder": "项目ID", + "cwidth": 15 + }, + { + "name": "status_input", + "uitype": "code", + "placeholder": "章节状态", + "cwidth": 15, + "valueField": "status_input", + "textField": "status_input_text", + "params": { + "dbname": "{{get_module_dbname('pipeline_bidding')}}", + "table": "appcodes_kv", + "tblvalue": "k", + "tbltext": "v", + "valueField": "status_input", + "textField": "status_input_text", + "cond": "parentid='bid_chapter_status'" + }, + "dataurl": "{{entire_url('/appbase/get_code.dspy')}}?prepend_all=1" + }, + { + "name": "title_input", + "uitype": "str", + "placeholder": "章节标题", + "cwidth": 15 + } +] + }, + + + + + + "page_rows":160, + "cache_limit":5 + } + + ,"binds":[] + +}] +} \ No newline at end of file diff --git a/wwwroot/bid_chapters/update_bid_chapters.dspy b/wwwroot/bid_chapters/update_bid_chapters.dspy new file mode 100644 index 0000000..52c65e0 --- /dev/null +++ b/wwwroot/bid_chapters/update_bid_chapters.dspy @@ -0,0 +1,44 @@ + +ns = params_kw.copy() +for k,v in list(ns.items()): + if v == 'NaN' or v == 'null' or v == '' or v is None: + if k != 'id': + del ns[k] + else: + ns[k] = None if v in ('NaN', 'null') else v + + + + + +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + + tblfields = json.loads('[{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"}, {"name": "project_id", "title": "项目ID", "type": "str", "length": 32, "nullable": "no"}, {"name": "chapter_no", "title": "章节号", "type": "str", "length": 32, "nullable": "no"}, {"name": "parent_no", "title": "父章节号", "type": "str", "length": 32}, {"name": "title", "title": "章节标题", "type": "str", "length": 255, "nullable": "no"}, {"name": "section", "title": "所属部分", "type": "str", "length": 32, "default": "technical"}, {"name": "scoring_item_ids", "title": "对应评分项(JSON)", "type": "text"}, {"name": "outline", "title": "章节要点", "type": "text"}, {"name": "status", "title": "章节状态", "type": "str", "length": 16, "nullable": "no", "default": "pending"}, {"name": "version", "title": "版本号", "type": "int", "nullable": "no", "default": "1"}, {"name": "content", "title": "章节正文", "type": "text"}, {"name": "file_path", "title": "章节文件路径", "type": "str", "length": 500}, {"name": "word_count", "title": "字数", "type": "int", "default": "0"}, {"name": "review_score", "title": "评审得分", "type": "float", "length": 8, "dec": 2}, {"name": "max_score", "title": "对应满分", "type": "float", "length": 8, "dec": 2}, {"name": "review_comment", "title": "评审意见/改进意见", "type": "text"}, {"name": "revise_count", "title": "重做次数", "type": "int", "nullable": "no", "default": "0"}, {"name": "assignee_role", "title": "承办角色", "type": "str", "length": 64, "default": "agent.bid_writer"}, {"name": "task_id", "title": "关联任务ID", "type": "str", "length": 32}, {"name": "order_no", "title": "排序", "type": "int", "default": "0"}, {"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"}, {"name": "updated_at", "title": "更新时间", "type": "timestamp"}]') + tblfname = [ f['name'] for f in tblfields ] + other_keys = [ k for k in ns.keys() if k in tblfname and k != 'id' ] + if len(other_keys) > 0: + r = await sor.U('bid_chapters', ns) + debug('update success'); + return { + "widgettype":"Message", + "options":{ + "title":"Update Success", + "cwidth":16, + "cheight":9, + "timeout":3, + "message":"ok" + } + } + +return { + "widgettype":"Error", + "options":{ + "title":"Update Error", + "cwidth":16, + "cheight":9, + "timeout":3, + "message":"failed" + } +} \ No newline at end of file diff --git a/wwwroot/bid_doc_requirements/add_bid_doc_requirements.dspy b/wwwroot/bid_doc_requirements/add_bid_doc_requirements.dspy new file mode 100644 index 0000000..ac85ccf --- /dev/null +++ b/wwwroot/bid_doc_requirements/add_bid_doc_requirements.dspy @@ -0,0 +1,41 @@ + +ns = params_kw.copy() +for k,v in ns.items(): + if v == 'NaN' or v == 'null': + ns[k] = None +id = params_kw.id +if not id or len(id) > 32: + id = uuid() +ns['id'] = id + + + + +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + r = await sor.C('bid_doc_requirements', ns.copy()) + _new_rows = await sor.sqlExe("SELECT * FROM bid_doc_requirements WHERE id = ${id}$", {'id': id}) + _user_data = _new_rows[0] if _new_rows else ns + return { + "widgettype":"Message", + "options":{ + "cwidth":16, + "cheight":9, + "title":"Add Success", + "timeout":3, + "message":"ok", + "user_data": _user_data + } + } + +return { + "widgettype":"Error", + "options":{ + "title":"Add Error", + "cwidth":16, + "cheight":9, + "timeout":3, + "message":"failed" + } +} \ No newline at end of file diff --git a/wwwroot/bid_doc_requirements/delete_bid_doc_requirements.dspy b/wwwroot/bid_doc_requirements/delete_bid_doc_requirements.dspy new file mode 100644 index 0000000..6987374 --- /dev/null +++ b/wwwroot/bid_doc_requirements/delete_bid_doc_requirements.dspy @@ -0,0 +1,33 @@ + +ns = { + 'id':params_kw['id'], +} + + +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + r = await sor.D('bid_doc_requirements', ns) + debug('delete success'); + return { + "widgettype":"Message", + "options":{ + "title":"Delete Success", + "timeout":3, + "cwidth":16, + "cheight":9, + "message":"ok" + } + } + +debug('Delete failed'); +return { + "widgettype":"Error", + "options":{ + "title":"Delete Error", + "timeout":3, + "cwidth":16, + "cheight":9, + "message":"failed" + } +} \ No newline at end of file diff --git a/wwwroot/bid_doc_requirements/get_bid_doc_requirements.dspy b/wwwroot/bid_doc_requirements/get_bid_doc_requirements.dspy new file mode 100644 index 0000000..6c416e0 --- /dev/null +++ b/wwwroot/bid_doc_requirements/get_bid_doc_requirements.dspy @@ -0,0 +1,49 @@ + +ns = params_kw.copy() + + +debug(f'get_bid_doc_requirements.dspy:{ns=}') +if not ns.get('page'): + ns['page'] = 1 +if not ns.get('sort'): + + + ns['sort'] = json.loads('["req_type", "order_no"]') + + + +sql = '''select a.*, b.req_type_text +from (select * from bid_doc_requirements where 1=1 [[filterstr]]) a left join (select k as req_type, + v as req_type_text from appcodes_kv where parentid='bid_req_type') b on a.req_type = b.req_type''' + + +filterjson = json.loads('{"AND": [{"field": "project_id", "op": "=", "var": "project_id"}, {"field": "req_type", "op": "=", "var": "type_input"}]}') + + +filterdic = ns.copy() +filterdic['filterstr'] = '' +filterdic['userorgid'] = '${userorgid}$' +filterdic['userid'] = '${userid}$' +if filterjson: + dbf = DBFilter(filterjson) + conds = dbf.gen(ns) + if conds: + ns.update(dbf.consts) + conds = f' and {conds}' + filterdic['filterstr'] = conds +ac = ArgsConvert('[[', ']]') +vars = ac.findAllVariables(sql) +NameSpace = {v:'${' + v + '}$' for v in vars if v != 'filterstr' } +filterdic.update(NameSpace) +sql = ac.convert(sql, filterdic) + +debug(f'{sql=}') +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + r = await sor.sqlPaging(sql, ns) + return r +return { + "total":0, + "rows":[] +} \ No newline at end of file diff --git a/wwwroot/bid_doc_requirements/index.ui b/wwwroot/bid_doc_requirements/index.ui new file mode 100644 index 0000000..35920c4 --- /dev/null +++ b/wwwroot/bid_doc_requirements/index.ui @@ -0,0 +1,231 @@ + +{ + "widgettype":"VBox", + "options":{"height":"100%","width":"100%"}, + "subwidgets":[ +{ + "id":"bid_doc_requirements_tbl", + "widgettype":"Tabular", + "options":{ + "width":"100%", + "height":"100%", + + + "title":"投标文件要求", + + + + + "toolbar":{ + "tools": [] +}, + + "css":"card", + + + "editable":{ + + "new_data_url": "{{entire_url('add_bid_doc_requirements.dspy')}}", + + + "delete_data_url": "{{entire_url('delete_bid_doc_requirements.dspy')}}", + + + "update_data_url": "{{entire_url('update_bid_doc_requirements.dspy')}}" + + + }, + + + "data_url":"{{entire_url('./get_bid_doc_requirements.dspy')}}", + + "data_method":"GET", + "data_params":{{json.dumps(params_kw, indent=4, ensure_ascii=False)}}, + "row_options":{ + + + + "browserfields": { + "exclouded": [] +}, + + + "editexclouded":[ + "id", + "created_at", + "updated_at" +], + + "fields":[ + { + "name": "id", + "title": "主键ID", + "type": "str", + "length": 32, + "nullable": "no", + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "主键ID" + }, + { + "name": "project_id", + "title": "项目ID", + "type": "str", + "length": 32, + "nullable": "no", + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "项目ID" + }, + { + "name": "req_type", + "title": "要求类型", + "type": "str", + "length": 32, + "nullable": "no", + "default": "structure", + "label": "要求类型", + "uitype": "code", + "valueField": "req_type", + "textField": "req_type_text", + "params": { + "dbname": "{{get_module_dbname('pipeline_bidding')}}", + "table": "appcodes_kv", + "tblvalue": "k", + "tbltext": "v", + "valueField": "req_type", + "textField": "req_type_text", + "cond": "parentid='bid_req_type'" + }, + "dataurl": "{{entire_url('/appbase/get_code.dspy')}}" + }, + { + "name": "chapter_no", + "title": "章节号", + "type": "str", + "length": 32, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "章节号" + }, + { + "name": "chapter_title", + "title": "章节标题", + "type": "str", + "length": 255, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "章节标题" + }, + { + "name": "requirement", + "title": "要求内容", + "type": "text", + "length": 0, + "uitype": "text", + "datatype": "text", + "label": "要求内容" + }, + { + "name": "page_limit", + "title": "页数限制", + "type": "int", + "length": 0, + "uitype": "int", + "datatype": "int", + "label": "页数限制" + }, + { + "name": "source_ref", + "title": "招标文件出处", + "type": "str", + "length": 500, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "招标文件出处" + }, + { + "name": "order_no", + "title": "排序", + "type": "int", + "default": "0", + "length": 0, + "uitype": "int", + "datatype": "int", + "label": "排序" + }, + { + "name": "created_at", + "title": "创建时间", + "type": "timestamp", + "nullable": "no", + "length": 0, + "uitype": "str", + "datatype": "timestamp", + "label": "创建时间" + }, + { + "name": "updated_at", + "title": "更新时间", + "type": "timestamp", + "length": 0, + "uitype": "str", + "datatype": "timestamp", + "label": "更新时间" + } +] + }, + + + + "search_form":{ + "css":"card", + "padding":"8px", + "show_label":false, + "submit_label":"搜索", + "submit_css":"primary", + "fields":[ + { + "name": "project_id", + "uitype": "str", + "placeholder": "项目ID", + "cwidth": 15 + }, + { + "name": "type_input", + "uitype": "code", + "placeholder": "要求类型", + "cwidth": 15, + "valueField": "type_input", + "textField": "type_input_text", + "params": { + "dbname": "{{get_module_dbname('pipeline_bidding')}}", + "table": "appcodes_kv", + "tblvalue": "k", + "tbltext": "v", + "valueField": "type_input", + "textField": "type_input_text", + "cond": "parentid='bid_req_type'" + }, + "dataurl": "{{entire_url('/appbase/get_code.dspy')}}?prepend_all=1" + } +] + }, + + + + + + "page_rows":160, + "cache_limit":5 + } + + ,"binds":[] + +}] +} \ No newline at end of file diff --git a/wwwroot/bid_doc_requirements/update_bid_doc_requirements.dspy b/wwwroot/bid_doc_requirements/update_bid_doc_requirements.dspy new file mode 100644 index 0000000..57a1148 --- /dev/null +++ b/wwwroot/bid_doc_requirements/update_bid_doc_requirements.dspy @@ -0,0 +1,44 @@ + +ns = params_kw.copy() +for k,v in list(ns.items()): + if v == 'NaN' or v == 'null' or v == '' or v is None: + if k != 'id': + del ns[k] + else: + ns[k] = None if v in ('NaN', 'null') else v + + + + + +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + + tblfields = json.loads('[{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"}, {"name": "project_id", "title": "项目ID", "type": "str", "length": 32, "nullable": "no"}, {"name": "req_type", "title": "要求类型", "type": "str", "length": 32, "nullable": "no", "default": "structure"}, {"name": "chapter_no", "title": "章节号", "type": "str", "length": 32}, {"name": "chapter_title", "title": "章节标题", "type": "str", "length": 255}, {"name": "requirement", "title": "要求内容", "type": "text"}, {"name": "page_limit", "title": "页数限制", "type": "int"}, {"name": "source_ref", "title": "招标文件出处", "type": "str", "length": 500}, {"name": "order_no", "title": "排序", "type": "int", "default": "0"}, {"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"}, {"name": "updated_at", "title": "更新时间", "type": "timestamp"}]') + tblfname = [ f['name'] for f in tblfields ] + other_keys = [ k for k in ns.keys() if k in tblfname and k != 'id' ] + if len(other_keys) > 0: + r = await sor.U('bid_doc_requirements', ns) + debug('update success'); + return { + "widgettype":"Message", + "options":{ + "title":"Update Success", + "cwidth":16, + "cheight":9, + "timeout":3, + "message":"ok" + } + } + +return { + "widgettype":"Error", + "options":{ + "title":"Update Error", + "cwidth":16, + "cheight":9, + "timeout":3, + "message":"failed" + } +} \ No newline at end of file diff --git a/wwwroot/bid_documents/add_bid_documents.dspy b/wwwroot/bid_documents/add_bid_documents.dspy new file mode 100644 index 0000000..254ec1b --- /dev/null +++ b/wwwroot/bid_documents/add_bid_documents.dspy @@ -0,0 +1,41 @@ + +ns = params_kw.copy() +for k,v in ns.items(): + if v == 'NaN' or v == 'null': + ns[k] = None +id = params_kw.id +if not id or len(id) > 32: + id = uuid() +ns['id'] = id + + + + +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + r = await sor.C('bid_documents', ns.copy()) + _new_rows = await sor.sqlExe("SELECT * FROM bid_documents WHERE id = ${id}$", {'id': id}) + _user_data = _new_rows[0] if _new_rows else ns + return { + "widgettype":"Message", + "options":{ + "cwidth":16, + "cheight":9, + "title":"Add Success", + "timeout":3, + "message":"ok", + "user_data": _user_data + } + } + +return { + "widgettype":"Error", + "options":{ + "title":"Add Error", + "cwidth":16, + "cheight":9, + "timeout":3, + "message":"failed" + } +} \ No newline at end of file diff --git a/wwwroot/bid_documents/delete_bid_documents.dspy b/wwwroot/bid_documents/delete_bid_documents.dspy new file mode 100644 index 0000000..cc7b9cf --- /dev/null +++ b/wwwroot/bid_documents/delete_bid_documents.dspy @@ -0,0 +1,33 @@ + +ns = { + 'id':params_kw['id'], +} + + +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + r = await sor.D('bid_documents', ns) + debug('delete success'); + return { + "widgettype":"Message", + "options":{ + "title":"Delete Success", + "timeout":3, + "cwidth":16, + "cheight":9, + "message":"ok" + } + } + +debug('Delete failed'); +return { + "widgettype":"Error", + "options":{ + "title":"Delete Error", + "timeout":3, + "cwidth":16, + "cheight":9, + "message":"failed" + } +} \ No newline at end of file diff --git a/wwwroot/bid_documents/get_bid_documents.dspy b/wwwroot/bid_documents/get_bid_documents.dspy new file mode 100644 index 0000000..8f710c0 --- /dev/null +++ b/wwwroot/bid_documents/get_bid_documents.dspy @@ -0,0 +1,49 @@ + +ns = params_kw.copy() + + +debug(f'get_bid_documents.dspy:{ns=}') +if not ns.get('page'): + ns['page'] = 1 +if not ns.get('sort'): + + + ns['sort'] = json.loads('["version desc"]') + + + +sql = '''select a.*, b.status_text +from (select * from bid_documents where 1=1 [[filterstr]]) a left join (select k as status, + v as status_text from appcodes_kv where parentid='bid_doc_status') b on a.status = b.status''' + + +filterjson = json.loads('{"AND": [{"field": "project_id", "op": "=", "var": "project_id"}, {"field": "status", "op": "=", "var": "status_input"}]}') + + +filterdic = ns.copy() +filterdic['filterstr'] = '' +filterdic['userorgid'] = '${userorgid}$' +filterdic['userid'] = '${userid}$' +if filterjson: + dbf = DBFilter(filterjson) + conds = dbf.gen(ns) + if conds: + ns.update(dbf.consts) + conds = f' and {conds}' + filterdic['filterstr'] = conds +ac = ArgsConvert('[[', ']]') +vars = ac.findAllVariables(sql) +NameSpace = {v:'${' + v + '}$' for v in vars if v != 'filterstr' } +filterdic.update(NameSpace) +sql = ac.convert(sql, filterdic) + +debug(f'{sql=}') +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + r = await sor.sqlPaging(sql, ns) + return r +return { + "total":0, + "rows":[] +} \ No newline at end of file diff --git a/wwwroot/bid_documents/index.ui b/wwwroot/bid_documents/index.ui new file mode 100644 index 0000000..85ff33e --- /dev/null +++ b/wwwroot/bid_documents/index.ui @@ -0,0 +1,289 @@ + +{ + "widgettype":"VBox", + "options":{"height":"100%","width":"100%"}, + "subwidgets":[ +{ + "id":"bid_documents_tbl", + "widgettype":"Tabular", + "options":{ + "width":"100%", + "height":"100%", + + + "title":"合成标书", + + + + + "toolbar":{ + "tools": [] +}, + + "css":"card", + + + "editable":{ + + "new_data_url": "{{entire_url('add_bid_documents.dspy')}}", + + + "delete_data_url": "{{entire_url('delete_bid_documents.dspy')}}", + + + "update_data_url": "{{entire_url('update_bid_documents.dspy')}}" + + + }, + + + "data_url":"{{entire_url('./get_bid_documents.dspy')}}", + + "data_method":"GET", + "data_params":{{json.dumps(params_kw, indent=4, ensure_ascii=False)}}, + "row_options":{ + + + + "browserfields": { + "exclouded": [] +}, + + + "editexclouded":[ + "id", + "created_at", + "updated_at", + "task_id", + "review_id" +], + + "fields":[ + { + "name": "id", + "title": "主键ID", + "type": "str", + "length": 32, + "nullable": "no", + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "主键ID" + }, + { + "name": "project_id", + "title": "项目ID", + "type": "str", + "length": 32, + "nullable": "no", + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "项目ID" + }, + { + "name": "version", + "title": "版本号", + "type": "int", + "nullable": "no", + "default": "1", + "length": 0, + "uitype": "int", + "datatype": "int", + "label": "版本号" + }, + { + "name": "doc_name", + "title": "标书名称", + "type": "str", + "length": 255, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "标书名称" + }, + { + "name": "file_path", + "title": "文件路径", + "type": "str", + "length": 500, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "文件路径" + }, + { + "name": "page_count", + "title": "页数", + "type": "int", + "default": "0", + "length": 0, + "uitype": "int", + "datatype": "int", + "label": "页数" + }, + { + "name": "chapter_count", + "title": "章节数", + "type": "int", + "default": "0", + "length": 0, + "uitype": "int", + "datatype": "int", + "label": "章节数" + }, + { + "name": "total_score", + "title": "总得分", + "type": "float", + "length": 8, + "dec": 2, + "cwidth": 8, + "uitype": "float", + "datatype": "float", + "label": "总得分" + }, + { + "name": "max_score", + "title": "总满分", + "type": "float", + "length": 8, + "dec": 2, + "cwidth": 8, + "uitype": "float", + "datatype": "float", + "label": "总满分" + }, + { + "name": "pass_ratio", + "title": "通过门限", + "type": "float", + "length": 5, + "dec": 4, + "cwidth": 5, + "uitype": "float", + "datatype": "float", + "label": "通过门限" + }, + { + "name": "status", + "title": "状态", + "type": "str", + "length": 16, + "nullable": "no", + "default": "draft", + "label": "状态", + "uitype": "code", + "valueField": "status", + "textField": "status_text", + "params": { + "dbname": "{{get_module_dbname('pipeline_bidding')}}", + "table": "appcodes_kv", + "tblvalue": "k", + "tbltext": "v", + "valueField": "status", + "textField": "status_text", + "cond": "parentid='bid_doc_status'" + }, + "dataurl": "{{entire_url('/appbase/get_code.dspy')}}" + }, + { + "name": "review_id", + "title": "评审记录ID", + "type": "str", + "length": 32, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "评审记录ID" + }, + { + "name": "task_id", + "title": "关联任务ID", + "type": "str", + "length": 32, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "关联任务ID" + }, + { + "name": "created_by", + "title": "创建人", + "type": "str", + "length": 32, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "创建人" + }, + { + "name": "created_at", + "title": "创建时间", + "type": "timestamp", + "nullable": "no", + "length": 0, + "uitype": "str", + "datatype": "timestamp", + "label": "创建时间" + }, + { + "name": "updated_at", + "title": "更新时间", + "type": "timestamp", + "length": 0, + "uitype": "str", + "datatype": "timestamp", + "label": "更新时间" + } +] + }, + + + + "search_form":{ + "css":"card", + "padding":"8px", + "show_label":false, + "submit_label":"搜索", + "submit_css":"primary", + "fields":[ + { + "name": "project_id", + "uitype": "str", + "placeholder": "项目ID", + "cwidth": 15 + }, + { + "name": "status_input", + "uitype": "code", + "placeholder": "状态", + "cwidth": 15, + "valueField": "status_input", + "textField": "status_input_text", + "params": { + "dbname": "{{get_module_dbname('pipeline_bidding')}}", + "table": "appcodes_kv", + "tblvalue": "k", + "tbltext": "v", + "valueField": "status_input", + "textField": "status_input_text", + "cond": "parentid='bid_doc_status'" + }, + "dataurl": "{{entire_url('/appbase/get_code.dspy')}}?prepend_all=1" + } +] + }, + + + + + + "page_rows":160, + "cache_limit":5 + } + + ,"binds":[] + +}] +} \ No newline at end of file diff --git a/wwwroot/bid_documents/update_bid_documents.dspy b/wwwroot/bid_documents/update_bid_documents.dspy new file mode 100644 index 0000000..5c70222 --- /dev/null +++ b/wwwroot/bid_documents/update_bid_documents.dspy @@ -0,0 +1,44 @@ + +ns = params_kw.copy() +for k,v in list(ns.items()): + if v == 'NaN' or v == 'null' or v == '' or v is None: + if k != 'id': + del ns[k] + else: + ns[k] = None if v in ('NaN', 'null') else v + + + + + +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + + tblfields = json.loads('[{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"}, {"name": "project_id", "title": "项目ID", "type": "str", "length": 32, "nullable": "no"}, {"name": "version", "title": "版本号", "type": "int", "nullable": "no", "default": "1"}, {"name": "doc_name", "title": "标书名称", "type": "str", "length": 255}, {"name": "file_path", "title": "文件路径", "type": "str", "length": 500}, {"name": "page_count", "title": "页数", "type": "int", "default": "0"}, {"name": "chapter_count", "title": "章节数", "type": "int", "default": "0"}, {"name": "total_score", "title": "总得分", "type": "float", "length": 8, "dec": 2}, {"name": "max_score", "title": "总满分", "type": "float", "length": 8, "dec": 2}, {"name": "pass_ratio", "title": "通过门限", "type": "float", "length": 5, "dec": 4}, {"name": "status", "title": "状态", "type": "str", "length": 16, "nullable": "no", "default": "draft"}, {"name": "review_id", "title": "评审记录ID", "type": "str", "length": 32}, {"name": "task_id", "title": "关联任务ID", "type": "str", "length": 32}, {"name": "created_by", "title": "创建人", "type": "str", "length": 32}, {"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"}, {"name": "updated_at", "title": "更新时间", "type": "timestamp"}]') + tblfname = [ f['name'] for f in tblfields ] + other_keys = [ k for k in ns.keys() if k in tblfname and k != 'id' ] + if len(other_keys) > 0: + r = await sor.U('bid_documents', ns) + debug('update success'); + return { + "widgettype":"Message", + "options":{ + "title":"Update Success", + "cwidth":16, + "cheight":9, + "timeout":3, + "message":"ok" + } + } + +return { + "widgettype":"Error", + "options":{ + "title":"Update Error", + "cwidth":16, + "cheight":9, + "timeout":3, + "message":"failed" + } +} \ No newline at end of file diff --git a/wwwroot/bid_kb_docs/add_bid_kb_docs.dspy b/wwwroot/bid_kb_docs/add_bid_kb_docs.dspy new file mode 100644 index 0000000..ee6769f --- /dev/null +++ b/wwwroot/bid_kb_docs/add_bid_kb_docs.dspy @@ -0,0 +1,41 @@ + +ns = params_kw.copy() +for k,v in ns.items(): + if v == 'NaN' or v == 'null': + ns[k] = None +id = params_kw.id +if not id or len(id) > 32: + id = uuid() +ns['id'] = id + + + + +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + r = await sor.C('bid_kb_docs', ns.copy()) + _new_rows = await sor.sqlExe("SELECT * FROM bid_kb_docs WHERE id = ${id}$", {'id': id}) + _user_data = _new_rows[0] if _new_rows else ns + return { + "widgettype":"Message", + "options":{ + "cwidth":16, + "cheight":9, + "title":"Add Success", + "timeout":3, + "message":"ok", + "user_data": _user_data + } + } + +return { + "widgettype":"Error", + "options":{ + "title":"Add Error", + "cwidth":16, + "cheight":9, + "timeout":3, + "message":"failed" + } +} \ No newline at end of file diff --git a/wwwroot/bid_kb_docs/delete_bid_kb_docs.dspy b/wwwroot/bid_kb_docs/delete_bid_kb_docs.dspy new file mode 100644 index 0000000..38af52d --- /dev/null +++ b/wwwroot/bid_kb_docs/delete_bid_kb_docs.dspy @@ -0,0 +1,33 @@ + +ns = { + 'id':params_kw['id'], +} + + +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + r = await sor.D('bid_kb_docs', ns) + debug('delete success'); + return { + "widgettype":"Message", + "options":{ + "title":"Delete Success", + "timeout":3, + "cwidth":16, + "cheight":9, + "message":"ok" + } + } + +debug('Delete failed'); +return { + "widgettype":"Error", + "options":{ + "title":"Delete Error", + "timeout":3, + "cwidth":16, + "cheight":9, + "message":"failed" + } +} \ No newline at end of file diff --git a/wwwroot/bid_kb_docs/get_bid_kb_docs.dspy b/wwwroot/bid_kb_docs/get_bid_kb_docs.dspy new file mode 100644 index 0000000..3131a98 --- /dev/null +++ b/wwwroot/bid_kb_docs/get_bid_kb_docs.dspy @@ -0,0 +1,49 @@ + +ns = params_kw.copy() + + +debug(f'get_bid_kb_docs.dspy:{ns=}') +if not ns.get('page'): + ns['page'] = 1 +if not ns.get('sort'): + + + ns['sort'] = json.loads('["doc_type", "created_at desc"]') + + + +sql = '''select a.*, b.doc_type_text +from (select * from bid_kb_docs where 1=1 [[filterstr]]) a left join (select k as doc_type, + v as doc_type_text from appcodes_kv where parentid='bid_kb_doc_type') b on a.doc_type = b.doc_type''' + + +filterjson = json.loads('{"AND": [{"field": "doc_name", "op": "LIKE", "var": "name_input"}, {"field": "doc_type", "op": "=", "var": "type_input"}, {"field": "status", "op": "=", "var": "status_input"}]}') + + +filterdic = ns.copy() +filterdic['filterstr'] = '' +filterdic['userorgid'] = '${userorgid}$' +filterdic['userid'] = '${userid}$' +if filterjson: + dbf = DBFilter(filterjson) + conds = dbf.gen(ns) + if conds: + ns.update(dbf.consts) + conds = f' and {conds}' + filterdic['filterstr'] = conds +ac = ArgsConvert('[[', ']]') +vars = ac.findAllVariables(sql) +NameSpace = {v:'${' + v + '}$' for v in vars if v != 'filterstr' } +filterdic.update(NameSpace) +sql = ac.convert(sql, filterdic) + +debug(f'{sql=}') +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + r = await sor.sqlPaging(sql, ns) + return r +return { + "total":0, + "rows":[] +} \ No newline at end of file diff --git a/wwwroot/bid_kb_docs/index.ui b/wwwroot/bid_kb_docs/index.ui new file mode 100644 index 0000000..75ff837 --- /dev/null +++ b/wwwroot/bid_kb_docs/index.ui @@ -0,0 +1,302 @@ + +{ + "widgettype":"VBox", + "options":{"height":"100%","width":"100%"}, + "subwidgets":[ +{ + "id":"bid_kb_docs_tbl", + "widgettype":"Tabular", + "options":{ + "width":"100%", + "height":"100%", + + + "title":"公司投标知识库", + + + + + "toolbar":{ + "tools": [] +}, + + "css":"card", + + + "editable":{ + + "new_data_url": "{{entire_url('add_bid_kb_docs.dspy')}}", + + + "delete_data_url": "{{entire_url('delete_bid_kb_docs.dspy')}}", + + + "update_data_url": "{{entire_url('update_bid_kb_docs.dspy')}}" + + + }, + + + "data_url":"{{entire_url('./get_bid_kb_docs.dspy')}}", + + "data_method":"GET", + "data_params":{{json.dumps(params_kw, indent=4, ensure_ascii=False)}}, + "row_options":{ + + + + "browserfields": { + "exclouded": [ + "content_text" + ] +}, + + + "editexclouded":[ + "id", + "created_at", + "updated_at" +], + + "fields":[ + { + "name": "id", + "title": "主键ID", + "type": "str", + "length": 32, + "nullable": "no", + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "主键ID" + }, + { + "name": "org_id", + "title": "机构ID", + "type": "str", + "length": 32, + "nullable": "no", + "default": "0", + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "机构ID" + }, + { + "name": "doc_type", + "title": "文档类型", + "type": "str", + "length": 32, + "nullable": "no", + "default": "qualification", + "label": "文档类型", + "uitype": "code", + "valueField": "doc_type", + "textField": "doc_type_text", + "params": { + "dbname": "{{get_module_dbname('pipeline_bidding')}}", + "table": "appcodes_kv", + "tblvalue": "k", + "tbltext": "v", + "valueField": "doc_type", + "textField": "doc_type_text", + "cond": "parentid='bid_kb_doc_type'" + }, + "dataurl": "{{entire_url('/appbase/get_code.dspy')}}" + }, + { + "name": "doc_name", + "title": "文档名称", + "type": "str", + "length": 255, + "nullable": "no", + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "文档名称" + }, + { + "name": "doc_no", + "title": "证书/合同编号", + "type": "str", + "length": 64, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "证书/合同编号" + }, + { + "name": "tags", + "title": "标签(逗号分隔)", + "type": "str", + "length": 500, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "标签(逗号分隔)" + }, + { + "name": "file_path", + "title": "文件路径", + "type": "str", + "length": 500, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "文件路径" + }, + { + "name": "content_text", + "title": "正文内容", + "type": "text", + "length": 0, + "uitype": "text", + "datatype": "text", + "label": "正文内容" + }, + { + "name": "issuer", + "title": "颁发/签约方", + "type": "str", + "length": 255, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "颁发/签约方" + }, + { + "name": "customer", + "title": "案例客户", + "type": "str", + "length": 255, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "案例客户" + }, + { + "name": "contract_amount", + "title": "合同金额", + "type": "float", + "length": 18, + "dec": 2, + "cwidth": 18, + "uitype": "float", + "datatype": "float", + "label": "合同金额" + }, + { + "name": "valid_from", + "title": "生效日期", + "type": "date", + "length": 0, + "uitype": "date", + "datatype": "date", + "label": "生效日期" + }, + { + "name": "valid_until", + "title": "有效期至", + "type": "date", + "length": 0, + "uitype": "date", + "datatype": "date", + "label": "有效期至" + }, + { + "name": "status", + "title": "状态", + "type": "str", + "length": 16, + "default": "active", + "cwidth": 16, + "uitype": "str", + "datatype": "str", + "label": "状态" + }, + { + "name": "created_by", + "title": "创建人", + "type": "str", + "length": 32, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "创建人" + }, + { + "name": "created_at", + "title": "创建时间", + "type": "timestamp", + "nullable": "no", + "length": 0, + "uitype": "str", + "datatype": "timestamp", + "label": "创建时间" + }, + { + "name": "updated_at", + "title": "更新时间", + "type": "timestamp", + "length": 0, + "uitype": "str", + "datatype": "timestamp", + "label": "更新时间" + } +] + }, + + + + "search_form":{ + "css":"card", + "padding":"8px", + "show_label":false, + "submit_label":"搜索", + "submit_css":"primary", + "fields":[ + { + "name": "name_input", + "uitype": "str", + "placeholder": "文档名称", + "cwidth": 15 + }, + { + "name": "type_input", + "uitype": "code", + "placeholder": "文档类型", + "cwidth": 15, + "valueField": "type_input", + "textField": "type_input_text", + "params": { + "dbname": "{{get_module_dbname('pipeline_bidding')}}", + "table": "appcodes_kv", + "tblvalue": "k", + "tbltext": "v", + "valueField": "type_input", + "textField": "type_input_text", + "cond": "parentid='bid_kb_doc_type'" + }, + "dataurl": "{{entire_url('/appbase/get_code.dspy')}}?prepend_all=1" + }, + { + "name": "status_input", + "uitype": "str", + "placeholder": "状态", + "cwidth": 15 + } +] + }, + + + + + + "page_rows":160, + "cache_limit":5 + } + + ,"binds":[] + +}] +} \ No newline at end of file diff --git a/wwwroot/bid_kb_docs/update_bid_kb_docs.dspy b/wwwroot/bid_kb_docs/update_bid_kb_docs.dspy new file mode 100644 index 0000000..60a0fbd --- /dev/null +++ b/wwwroot/bid_kb_docs/update_bid_kb_docs.dspy @@ -0,0 +1,44 @@ + +ns = params_kw.copy() +for k,v in list(ns.items()): + if v == 'NaN' or v == 'null' or v == '' or v is None: + if k != 'id': + del ns[k] + else: + ns[k] = None if v in ('NaN', 'null') else v + + + + + +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + + tblfields = json.loads('[{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"}, {"name": "org_id", "title": "机构ID", "type": "str", "length": 32, "nullable": "no", "default": "0"}, {"name": "doc_type", "title": "文档类型", "type": "str", "length": 32, "nullable": "no", "default": "qualification"}, {"name": "doc_name", "title": "文档名称", "type": "str", "length": 255, "nullable": "no"}, {"name": "doc_no", "title": "证书/合同编号", "type": "str", "length": 64}, {"name": "tags", "title": "标签(逗号分隔)", "type": "str", "length": 500}, {"name": "file_path", "title": "文件路径", "type": "str", "length": 500}, {"name": "content_text", "title": "正文内容", "type": "text"}, {"name": "issuer", "title": "颁发/签约方", "type": "str", "length": 255}, {"name": "customer", "title": "案例客户", "type": "str", "length": 255}, {"name": "contract_amount", "title": "合同金额", "type": "float", "length": 18, "dec": 2}, {"name": "valid_from", "title": "生效日期", "type": "date"}, {"name": "valid_until", "title": "有效期至", "type": "date"}, {"name": "status", "title": "状态", "type": "str", "length": 16, "default": "active"}, {"name": "created_by", "title": "创建人", "type": "str", "length": 32}, {"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"}, {"name": "updated_at", "title": "更新时间", "type": "timestamp"}]') + tblfname = [ f['name'] for f in tblfields ] + other_keys = [ k for k in ns.keys() if k in tblfname and k != 'id' ] + if len(other_keys) > 0: + r = await sor.U('bid_kb_docs', ns) + debug('update success'); + return { + "widgettype":"Message", + "options":{ + "title":"Update Success", + "cwidth":16, + "cheight":9, + "timeout":3, + "message":"ok" + } + } + +return { + "widgettype":"Error", + "options":{ + "title":"Update Error", + "cwidth":16, + "cheight":9, + "timeout":3, + "message":"failed" + } +} \ No newline at end of file diff --git a/wwwroot/bid_mail_accounts/add_bid_mail_accounts.dspy b/wwwroot/bid_mail_accounts/add_bid_mail_accounts.dspy new file mode 100644 index 0000000..3300c0e --- /dev/null +++ b/wwwroot/bid_mail_accounts/add_bid_mail_accounts.dspy @@ -0,0 +1,41 @@ + +ns = params_kw.copy() +for k,v in ns.items(): + if v == 'NaN' or v == 'null': + ns[k] = None +id = params_kw.id +if not id or len(id) > 32: + id = uuid() +ns['id'] = id + + + + +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + r = await sor.C('bid_mail_accounts', ns.copy()) + _new_rows = await sor.sqlExe("SELECT * FROM bid_mail_accounts WHERE id = ${id}$", {'id': id}) + _user_data = _new_rows[0] if _new_rows else ns + return { + "widgettype":"Message", + "options":{ + "cwidth":16, + "cheight":9, + "title":"Add Success", + "timeout":3, + "message":"ok", + "user_data": _user_data + } + } + +return { + "widgettype":"Error", + "options":{ + "title":"Add Error", + "cwidth":16, + "cheight":9, + "timeout":3, + "message":"failed" + } +} \ No newline at end of file diff --git a/wwwroot/bid_mail_accounts/delete_bid_mail_accounts.dspy b/wwwroot/bid_mail_accounts/delete_bid_mail_accounts.dspy new file mode 100644 index 0000000..93b767d --- /dev/null +++ b/wwwroot/bid_mail_accounts/delete_bid_mail_accounts.dspy @@ -0,0 +1,33 @@ + +ns = { + 'id':params_kw['id'], +} + + +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + r = await sor.D('bid_mail_accounts', ns) + debug('delete success'); + return { + "widgettype":"Message", + "options":{ + "title":"Delete Success", + "timeout":3, + "cwidth":16, + "cheight":9, + "message":"ok" + } + } + +debug('Delete failed'); +return { + "widgettype":"Error", + "options":{ + "title":"Delete Error", + "timeout":3, + "cwidth":16, + "cheight":9, + "message":"failed" + } +} \ No newline at end of file diff --git a/wwwroot/bid_mail_accounts/get_bid_mail_accounts.dspy b/wwwroot/bid_mail_accounts/get_bid_mail_accounts.dspy new file mode 100644 index 0000000..eb2b073 --- /dev/null +++ b/wwwroot/bid_mail_accounts/get_bid_mail_accounts.dspy @@ -0,0 +1,47 @@ + +ns = params_kw.copy() + + +debug(f'get_bid_mail_accounts.dspy:{ns=}') +if not ns.get('page'): + ns['page'] = 1 +if not ns.get('sort'): + + + ns['sort'] = json.loads('["created_at desc"]') + + + +sql = '''select * from bid_mail_accounts where 1=1 [[filterstr]]''' + + +filterjson = json.loads('{"AND": [{"field": "account_name", "op": "LIKE", "var": "name_input"}, {"field": "enabled", "op": "=", "var": "enabled_input"}]}') + + +filterdic = ns.copy() +filterdic['filterstr'] = '' +filterdic['userorgid'] = '${userorgid}$' +filterdic['userid'] = '${userid}$' +if filterjson: + dbf = DBFilter(filterjson) + conds = dbf.gen(ns) + if conds: + ns.update(dbf.consts) + conds = f' and {conds}' + filterdic['filterstr'] = conds +ac = ArgsConvert('[[', ']]') +vars = ac.findAllVariables(sql) +NameSpace = {v:'${' + v + '}$' for v in vars if v != 'filterstr' } +filterdic.update(NameSpace) +sql = ac.convert(sql, filterdic) + +debug(f'{sql=}') +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + r = await sor.sqlPaging(sql, ns) + return r +return { + "total":0, + "rows":[] +} \ No newline at end of file diff --git a/wwwroot/bid_mail_accounts/index.ui b/wwwroot/bid_mail_accounts/index.ui new file mode 100644 index 0000000..0ef475a --- /dev/null +++ b/wwwroot/bid_mail_accounts/index.ui @@ -0,0 +1,307 @@ + +{ + "widgettype":"VBox", + "options":{"height":"100%","width":"100%"}, + "subwidgets":[ +{ + "id":"bid_mail_accounts_tbl", + "widgettype":"Tabular", + "options":{ + "width":"100%", + "height":"100%", + + + "title":"招标邮箱配置", + + + + + "toolbar":{ + "tools": [] +}, + + "css":"card", + + + "editable":{ + + "new_data_url": "{{entire_url('add_bid_mail_accounts.dspy')}}", + + + "delete_data_url": "{{entire_url('delete_bid_mail_accounts.dspy')}}", + + + "update_data_url": "{{entire_url('update_bid_mail_accounts.dspy')}}" + + + }, + + + "data_url":"{{entire_url('./get_bid_mail_accounts.dspy')}}", + + "data_method":"GET", + "data_params":{{json.dumps(params_kw, indent=4, ensure_ascii=False)}}, + "row_options":{ + + + + "browserfields": { + "exclouded": [ + "password", + "last_uid" + ] +}, + + + "editexclouded":[ + "id", + "created_at", + "updated_at", + "last_uid", + "last_fetch_at" +], + + "fields":[ + { + "name": "id", + "title": "主键ID", + "type": "str", + "length": 32, + "nullable": "no", + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "主键ID" + }, + { + "name": "org_id", + "title": "机构ID", + "type": "str", + "length": 32, + "nullable": "no", + "default": "0", + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "机构ID" + }, + { + "name": "account_name", + "title": "配置名称", + "type": "str", + "length": 128, + "nullable": "no", + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "配置名称" + }, + { + "name": "protocol", + "title": "协议", + "type": "str", + "length": 16, + "nullable": "no", + "default": "imap", + "cwidth": 16, + "uitype": "str", + "datatype": "str", + "label": "协议" + }, + { + "name": "host", + "title": "服务器地址", + "type": "str", + "length": 128, + "nullable": "no", + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "服务器地址" + }, + { + "name": "port", + "title": "端口", + "type": "int", + "nullable": "no", + "default": "993", + "length": 0, + "uitype": "int", + "datatype": "int", + "label": "端口" + }, + { + "name": "use_ssl", + "title": "使用SSL", + "type": "str", + "length": 1, + "nullable": "no", + "default": "1", + "cwidth": 4, + "uitype": "str", + "datatype": "str", + "label": "使用SSL" + }, + { + "name": "username", + "title": "登录账号", + "type": "str", + "length": 128, + "nullable": "no", + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "登录账号" + }, + { + "name": "password", + "title": "登录密码(AES加密)", + "type": "str", + "length": 500, + "nullable": "no", + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "登录密码(AES加密)" + }, + { + "name": "folder", + "title": "邮件目录", + "type": "str", + "length": 64, + "default": "INBOX", + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "邮件目录" + }, + { + "name": "filter_keywords", + "title": "招标关键词(逗号分隔)", + "type": "str", + "length": 500, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "招标关键词(逗号分隔)" + }, + { + "name": "fetch_days", + "title": "回溯天数", + "type": "int", + "default": "7", + "length": 0, + "uitype": "int", + "datatype": "int", + "label": "回溯天数" + }, + { + "name": "last_uid", + "title": "上次同步UID", + "type": "str", + "length": 64, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "上次同步UID" + }, + { + "name": "last_fetch_at", + "title": "上次同步时间", + "type": "timestamp", + "length": 0, + "uitype": "str", + "datatype": "timestamp", + "label": "上次同步时间" + }, + { + "name": "enabled", + "title": "启用", + "type": "str", + "length": 1, + "nullable": "no", + "default": "1", + "cwidth": 4, + "uitype": "str", + "datatype": "str", + "label": "启用" + }, + { + "name": "status", + "title": "状态", + "type": "str", + "length": 16, + "default": "active", + "cwidth": 16, + "uitype": "str", + "datatype": "str", + "label": "状态" + }, + { + "name": "created_by", + "title": "创建人", + "type": "str", + "length": 32, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "创建人" + }, + { + "name": "created_at", + "title": "创建时间", + "type": "timestamp", + "nullable": "no", + "length": 0, + "uitype": "str", + "datatype": "timestamp", + "label": "创建时间" + }, + { + "name": "updated_at", + "title": "更新时间", + "type": "timestamp", + "length": 0, + "uitype": "str", + "datatype": "timestamp", + "label": "更新时间" + } +] + }, + + + + "search_form":{ + "css":"card", + "padding":"8px", + "show_label":false, + "submit_label":"搜索", + "submit_css":"primary", + "fields":[ + { + "name": "name_input", + "uitype": "str", + "placeholder": "配置名称", + "cwidth": 15 + }, + { + "name": "enabled_input", + "uitype": "str", + "placeholder": "启用", + "cwidth": 15 + } +] + }, + + + + + + "page_rows":160, + "cache_limit":5 + } + + ,"binds":[] + +}] +} \ No newline at end of file diff --git a/wwwroot/bid_mail_accounts/update_bid_mail_accounts.dspy b/wwwroot/bid_mail_accounts/update_bid_mail_accounts.dspy new file mode 100644 index 0000000..be34502 --- /dev/null +++ b/wwwroot/bid_mail_accounts/update_bid_mail_accounts.dspy @@ -0,0 +1,44 @@ + +ns = params_kw.copy() +for k,v in list(ns.items()): + if v == 'NaN' or v == 'null' or v == '' or v is None: + if k != 'id': + del ns[k] + else: + ns[k] = None if v in ('NaN', 'null') else v + + + + + +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + + tblfields = json.loads('[{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"}, {"name": "org_id", "title": "机构ID", "type": "str", "length": 32, "nullable": "no", "default": "0"}, {"name": "account_name", "title": "配置名称", "type": "str", "length": 128, "nullable": "no"}, {"name": "protocol", "title": "协议", "type": "str", "length": 16, "nullable": "no", "default": "imap"}, {"name": "host", "title": "服务器地址", "type": "str", "length": 128, "nullable": "no"}, {"name": "port", "title": "端口", "type": "int", "nullable": "no", "default": "993"}, {"name": "use_ssl", "title": "使用SSL", "type": "str", "length": 1, "nullable": "no", "default": "1"}, {"name": "username", "title": "登录账号", "type": "str", "length": 128, "nullable": "no"}, {"name": "password", "title": "登录密码(AES加密)", "type": "str", "length": 500, "nullable": "no"}, {"name": "folder", "title": "邮件目录", "type": "str", "length": 64, "default": "INBOX"}, {"name": "filter_keywords", "title": "招标关键词(逗号分隔)", "type": "str", "length": 500}, {"name": "fetch_days", "title": "回溯天数", "type": "int", "default": "7"}, {"name": "last_uid", "title": "上次同步UID", "type": "str", "length": 64}, {"name": "last_fetch_at", "title": "上次同步时间", "type": "timestamp"}, {"name": "enabled", "title": "启用", "type": "str", "length": 1, "nullable": "no", "default": "1"}, {"name": "status", "title": "状态", "type": "str", "length": 16, "default": "active"}, {"name": "created_by", "title": "创建人", "type": "str", "length": 32}, {"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"}, {"name": "updated_at", "title": "更新时间", "type": "timestamp"}]') + tblfname = [ f['name'] for f in tblfields ] + other_keys = [ k for k in ns.keys() if k in tblfname and k != 'id' ] + if len(other_keys) > 0: + r = await sor.U('bid_mail_accounts', ns) + debug('update success'); + return { + "widgettype":"Message", + "options":{ + "title":"Update Success", + "cwidth":16, + "cheight":9, + "timeout":3, + "message":"ok" + } + } + +return { + "widgettype":"Error", + "options":{ + "title":"Update Error", + "cwidth":16, + "cheight":9, + "timeout":3, + "message":"failed" + } +} \ No newline at end of file diff --git a/wwwroot/bid_members/add_bid_members.dspy b/wwwroot/bid_members/add_bid_members.dspy new file mode 100644 index 0000000..913eca2 --- /dev/null +++ b/wwwroot/bid_members/add_bid_members.dspy @@ -0,0 +1,41 @@ + +ns = params_kw.copy() +for k,v in ns.items(): + if v == 'NaN' or v == 'null': + ns[k] = None +id = params_kw.id +if not id or len(id) > 32: + id = uuid() +ns['id'] = id + + + + +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + r = await sor.C('bid_members', ns.copy()) + _new_rows = await sor.sqlExe("SELECT * FROM bid_members WHERE id = ${id}$", {'id': id}) + _user_data = _new_rows[0] if _new_rows else ns + return { + "widgettype":"Message", + "options":{ + "cwidth":16, + "cheight":9, + "title":"Add Success", + "timeout":3, + "message":"ok", + "user_data": _user_data + } + } + +return { + "widgettype":"Error", + "options":{ + "title":"Add Error", + "cwidth":16, + "cheight":9, + "timeout":3, + "message":"failed" + } +} \ No newline at end of file diff --git a/wwwroot/bid_members/delete_bid_members.dspy b/wwwroot/bid_members/delete_bid_members.dspy new file mode 100644 index 0000000..4f0ae0f --- /dev/null +++ b/wwwroot/bid_members/delete_bid_members.dspy @@ -0,0 +1,33 @@ + +ns = { + 'id':params_kw['id'], +} + + +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + r = await sor.D('bid_members', ns) + debug('delete success'); + return { + "widgettype":"Message", + "options":{ + "title":"Delete Success", + "timeout":3, + "cwidth":16, + "cheight":9, + "message":"ok" + } + } + +debug('Delete failed'); +return { + "widgettype":"Error", + "options":{ + "title":"Delete Error", + "timeout":3, + "cwidth":16, + "cheight":9, + "message":"failed" + } +} \ No newline at end of file diff --git a/wwwroot/bid_members/get_bid_members.dspy b/wwwroot/bid_members/get_bid_members.dspy new file mode 100644 index 0000000..a983970 --- /dev/null +++ b/wwwroot/bid_members/get_bid_members.dspy @@ -0,0 +1,49 @@ + +ns = params_kw.copy() + + +debug(f'get_bid_members.dspy:{ns=}') +if not ns.get('page'): + ns['page'] = 1 +if not ns.get('sort'): + + + ns['sort'] = json.loads('["created_at"]') + + + +sql = '''select a.*, b.member_role_text +from (select * from bid_members where 1=1 [[filterstr]]) a left join (select k as member_role, + v as member_role_text from appcodes_kv where parentid='bid_member_role') b on a.member_role = b.member_role''' + + +filterjson = json.loads('{"AND": [{"field": "project_id", "op": "=", "var": "project_id"}, {"field": "user_name", "op": "LIKE", "var": "name_input"}]}') + + +filterdic = ns.copy() +filterdic['filterstr'] = '' +filterdic['userorgid'] = '${userorgid}$' +filterdic['userid'] = '${userid}$' +if filterjson: + dbf = DBFilter(filterjson) + conds = dbf.gen(ns) + if conds: + ns.update(dbf.consts) + conds = f' and {conds}' + filterdic['filterstr'] = conds +ac = ArgsConvert('[[', ']]') +vars = ac.findAllVariables(sql) +NameSpace = {v:'${' + v + '}$' for v in vars if v != 'filterstr' } +filterdic.update(NameSpace) +sql = ac.convert(sql, filterdic) + +debug(f'{sql=}') +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + r = await sor.sqlPaging(sql, ns) + return r +return { + "total":0, + "rows":[] +} \ No newline at end of file diff --git a/wwwroot/bid_members/index.ui b/wwwroot/bid_members/index.ui new file mode 100644 index 0000000..a9f0138 --- /dev/null +++ b/wwwroot/bid_members/index.ui @@ -0,0 +1,212 @@ + +{ + "widgettype":"VBox", + "options":{"height":"100%","width":"100%"}, + "subwidgets":[ +{ + "id":"bid_members_tbl", + "widgettype":"Tabular", + "options":{ + "width":"100%", + "height":"100%", + + + "title":"投标项目参与人员", + + + + + "toolbar":{ + "tools": [] +}, + + "css":"card", + + + "editable":{ + + "new_data_url": "{{entire_url('add_bid_members.dspy')}}", + + + "delete_data_url": "{{entire_url('delete_bid_members.dspy')}}", + + + "update_data_url": "{{entire_url('update_bid_members.dspy')}}" + + + }, + + + "data_url":"{{entire_url('./get_bid_members.dspy')}}", + + "data_method":"GET", + "data_params":{{json.dumps(params_kw, indent=4, ensure_ascii=False)}}, + "row_options":{ + + + + "browserfields": { + "exclouded": [] +}, + + + "editexclouded":[ + "id", + "created_at", + "updated_at" +], + + "fields":[ + { + "name": "id", + "title": "主键ID", + "type": "str", + "length": 32, + "nullable": "no", + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "主键ID" + }, + { + "name": "project_id", + "title": "项目ID", + "type": "str", + "length": 32, + "nullable": "no", + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "项目ID" + }, + { + "name": "user_id", + "title": "用户ID", + "type": "str", + "length": 32, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "用户ID" + }, + { + "name": "user_name", + "title": "姓名", + "type": "str", + "length": 128, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "姓名" + }, + { + "name": "member_role", + "title": "承担角色", + "type": "str", + "length": 64, + "nullable": "no", + "default": "writer", + "label": "承担角色", + "uitype": "code", + "valueField": "member_role", + "textField": "member_role_text", + "params": { + "dbname": "{{get_module_dbname('pipeline_bidding')}}", + "table": "appcodes_kv", + "tblvalue": "k", + "tbltext": "v", + "valueField": "member_role", + "textField": "member_role_text", + "cond": "parentid='bid_member_role'" + }, + "dataurl": "{{entire_url('/appbase/get_code.dspy')}}" + }, + { + "name": "duty", + "title": "职责说明", + "type": "str", + "length": 500, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "职责说明" + }, + { + "name": "status", + "title": "状态", + "type": "str", + "length": 16, + "default": "active", + "cwidth": 16, + "uitype": "str", + "datatype": "str", + "label": "状态" + }, + { + "name": "created_by", + "title": "创建人", + "type": "str", + "length": 32, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "创建人" + }, + { + "name": "created_at", + "title": "创建时间", + "type": "timestamp", + "nullable": "no", + "length": 0, + "uitype": "str", + "datatype": "timestamp", + "label": "创建时间" + }, + { + "name": "updated_at", + "title": "更新时间", + "type": "timestamp", + "length": 0, + "uitype": "str", + "datatype": "timestamp", + "label": "更新时间" + } +] + }, + + + + "search_form":{ + "css":"card", + "padding":"8px", + "show_label":false, + "submit_label":"搜索", + "submit_css":"primary", + "fields":[ + { + "name": "project_id", + "uitype": "str", + "placeholder": "项目ID", + "cwidth": 15 + }, + { + "name": "name_input", + "uitype": "str", + "placeholder": "姓名", + "cwidth": 15 + } +] + }, + + + + + + "page_rows":160, + "cache_limit":5 + } + + ,"binds":[] + +}] +} \ No newline at end of file diff --git a/wwwroot/bid_members/update_bid_members.dspy b/wwwroot/bid_members/update_bid_members.dspy new file mode 100644 index 0000000..faba915 --- /dev/null +++ b/wwwroot/bid_members/update_bid_members.dspy @@ -0,0 +1,44 @@ + +ns = params_kw.copy() +for k,v in list(ns.items()): + if v == 'NaN' or v == 'null' or v == '' or v is None: + if k != 'id': + del ns[k] + else: + ns[k] = None if v in ('NaN', 'null') else v + + + + + +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + + tblfields = json.loads('[{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"}, {"name": "project_id", "title": "项目ID", "type": "str", "length": 32, "nullable": "no"}, {"name": "user_id", "title": "用户ID", "type": "str", "length": 32}, {"name": "user_name", "title": "姓名", "type": "str", "length": 128}, {"name": "member_role", "title": "承担角色", "type": "str", "length": 64, "nullable": "no", "default": "writer"}, {"name": "duty", "title": "职责说明", "type": "str", "length": 500}, {"name": "status", "title": "状态", "type": "str", "length": 16, "default": "active"}, {"name": "created_by", "title": "创建人", "type": "str", "length": 32}, {"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"}, {"name": "updated_at", "title": "更新时间", "type": "timestamp"}]') + tblfname = [ f['name'] for f in tblfields ] + other_keys = [ k for k in ns.keys() if k in tblfname and k != 'id' ] + if len(other_keys) > 0: + r = await sor.U('bid_members', ns) + debug('update success'); + return { + "widgettype":"Message", + "options":{ + "title":"Update Success", + "cwidth":16, + "cheight":9, + "timeout":3, + "message":"ok" + } + } + +return { + "widgettype":"Error", + "options":{ + "title":"Update Error", + "cwidth":16, + "cheight":9, + "timeout":3, + "message":"failed" + } +} \ No newline at end of file diff --git a/wwwroot/bid_qualifications/add_bid_qualifications.dspy b/wwwroot/bid_qualifications/add_bid_qualifications.dspy new file mode 100644 index 0000000..7fa264f --- /dev/null +++ b/wwwroot/bid_qualifications/add_bid_qualifications.dspy @@ -0,0 +1,41 @@ + +ns = params_kw.copy() +for k,v in ns.items(): + if v == 'NaN' or v == 'null': + ns[k] = None +id = params_kw.id +if not id or len(id) > 32: + id = uuid() +ns['id'] = id + + + + +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + r = await sor.C('bid_qualifications', ns.copy()) + _new_rows = await sor.sqlExe("SELECT * FROM bid_qualifications WHERE id = ${id}$", {'id': id}) + _user_data = _new_rows[0] if _new_rows else ns + return { + "widgettype":"Message", + "options":{ + "cwidth":16, + "cheight":9, + "title":"Add Success", + "timeout":3, + "message":"ok", + "user_data": _user_data + } + } + +return { + "widgettype":"Error", + "options":{ + "title":"Add Error", + "cwidth":16, + "cheight":9, + "timeout":3, + "message":"failed" + } +} \ No newline at end of file diff --git a/wwwroot/bid_qualifications/delete_bid_qualifications.dspy b/wwwroot/bid_qualifications/delete_bid_qualifications.dspy new file mode 100644 index 0000000..5360381 --- /dev/null +++ b/wwwroot/bid_qualifications/delete_bid_qualifications.dspy @@ -0,0 +1,33 @@ + +ns = { + 'id':params_kw['id'], +} + + +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + r = await sor.D('bid_qualifications', ns) + debug('delete success'); + return { + "widgettype":"Message", + "options":{ + "title":"Delete Success", + "timeout":3, + "cwidth":16, + "cheight":9, + "message":"ok" + } + } + +debug('Delete failed'); +return { + "widgettype":"Error", + "options":{ + "title":"Delete Error", + "timeout":3, + "cwidth":16, + "cheight":9, + "message":"failed" + } +} \ No newline at end of file diff --git a/wwwroot/bid_qualifications/get_bid_qualifications.dspy b/wwwroot/bid_qualifications/get_bid_qualifications.dspy new file mode 100644 index 0000000..2e76103 --- /dev/null +++ b/wwwroot/bid_qualifications/get_bid_qualifications.dspy @@ -0,0 +1,50 @@ + +ns = params_kw.copy() + + +debug(f'get_bid_qualifications.dspy:{ns=}') +if not ns.get('page'): + ns['page'] = 1 +if not ns.get('sort'): + + + ns['sort'] = json.loads('["created_at"]') + + + +sql = '''select a.*, b.match_status_text, c.owner_text +from (select * from bid_qualifications where 1=1 [[filterstr]]) a left join (select k as match_status, + v as match_status_text from appcodes_kv where parentid='bid_qual_match_status') b on a.match_status = b.match_status left join (select k as owner, + v as owner_text from appcodes_kv where parentid='bid_qual_owner') c on a.owner = c.owner''' + + +filterjson = json.loads('{"AND": [{"field": "project_id", "op": "=", "var": "project_id"}, {"field": "match_status", "op": "=", "var": "status_input"}]}') + + +filterdic = ns.copy() +filterdic['filterstr'] = '' +filterdic['userorgid'] = '${userorgid}$' +filterdic['userid'] = '${userid}$' +if filterjson: + dbf = DBFilter(filterjson) + conds = dbf.gen(ns) + if conds: + ns.update(dbf.consts) + conds = f' and {conds}' + filterdic['filterstr'] = conds +ac = ArgsConvert('[[', ']]') +vars = ac.findAllVariables(sql) +NameSpace = {v:'${' + v + '}$' for v in vars if v != 'filterstr' } +filterdic.update(NameSpace) +sql = ac.convert(sql, filterdic) + +debug(f'{sql=}') +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + r = await sor.sqlPaging(sql, ns) + return r +return { + "total":0, + "rows":[] +} \ No newline at end of file diff --git a/wwwroot/bid_qualifications/index.ui b/wwwroot/bid_qualifications/index.ui new file mode 100644 index 0000000..5e4699f --- /dev/null +++ b/wwwroot/bid_qualifications/index.ui @@ -0,0 +1,268 @@ + +{ + "widgettype":"VBox", + "options":{"height":"100%","width":"100%"}, + "subwidgets":[ +{ + "id":"bid_qualifications_tbl", + "widgettype":"Tabular", + "options":{ + "width":"100%", + "height":"100%", + + + "title":"招标所需资质", + + + + + "toolbar":{ + "tools": [] +}, + + "css":"card", + + + "editable":{ + + "new_data_url": "{{entire_url('add_bid_qualifications.dspy')}}", + + + "delete_data_url": "{{entire_url('delete_bid_qualifications.dspy')}}", + + + "update_data_url": "{{entire_url('update_bid_qualifications.dspy')}}" + + + }, + + + "data_url":"{{entire_url('./get_bid_qualifications.dspy')}}", + + "data_method":"GET", + "data_params":{{json.dumps(params_kw, indent=4, ensure_ascii=False)}}, + "row_options":{ + + + + "browserfields": { + "exclouded": [] +}, + + + "editexclouded":[ + "id", + "created_at", + "updated_at", + "human_task_id" +], + + "fields":[ + { + "name": "id", + "title": "主键ID", + "type": "str", + "length": 32, + "nullable": "no", + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "主键ID" + }, + { + "name": "project_id", + "title": "项目ID", + "type": "str", + "length": 32, + "nullable": "no", + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "项目ID" + }, + { + "name": "qual_name", + "title": "资质名称", + "type": "str", + "length": 255, + "nullable": "no", + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "资质名称" + }, + { + "name": "requirement", + "title": "资质要求原文", + "type": "text", + "length": 0, + "uitype": "text", + "datatype": "text", + "label": "资质要求原文" + }, + { + "name": "is_mandatory", + "title": "是否必需", + "type": "str", + "length": 1, + "nullable": "no", + "default": "1", + "cwidth": 4, + "uitype": "str", + "datatype": "str", + "label": "是否必需" + }, + { + "name": "owner", + "title": "提供方", + "type": "str", + "length": 16, + "nullable": "no", + "default": "kb", + "label": "提供方", + "uitype": "code", + "valueField": "owner", + "textField": "owner_text", + "params": { + "dbname": "{{get_module_dbname('pipeline_bidding')}}", + "table": "appcodes_kv", + "tblvalue": "k", + "tbltext": "v", + "valueField": "owner", + "textField": "owner_text", + "cond": "parentid='bid_qual_owner'" + }, + "dataurl": "{{entire_url('/appbase/get_code.dspy')}}" + }, + { + "name": "kb_doc_id", + "title": "知识库文档ID", + "type": "str", + "length": 32, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "知识库文档ID" + }, + { + "name": "match_status", + "title": "匹配状态", + "type": "str", + "length": 16, + "nullable": "no", + "default": "pending", + "label": "匹配状态", + "uitype": "code", + "valueField": "match_status", + "textField": "match_status_text", + "params": { + "dbname": "{{get_module_dbname('pipeline_bidding')}}", + "table": "appcodes_kv", + "tblvalue": "k", + "tbltext": "v", + "valueField": "match_status", + "textField": "match_status_text", + "cond": "parentid='bid_qual_match_status'" + }, + "dataurl": "{{entire_url('/appbase/get_code.dspy')}}" + }, + { + "name": "file_path", + "title": "文档路径", + "type": "str", + "length": 500, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "文档路径" + }, + { + "name": "note", + "title": "说明", + "type": "str", + "length": 500, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "说明" + }, + { + "name": "human_task_id", + "title": "人类任务ID", + "type": "str", + "length": 32, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "人类任务ID" + }, + { + "name": "created_at", + "title": "创建时间", + "type": "timestamp", + "nullable": "no", + "length": 0, + "uitype": "str", + "datatype": "timestamp", + "label": "创建时间" + }, + { + "name": "updated_at", + "title": "更新时间", + "type": "timestamp", + "length": 0, + "uitype": "str", + "datatype": "timestamp", + "label": "更新时间" + } +] + }, + + + + "search_form":{ + "css":"card", + "padding":"8px", + "show_label":false, + "submit_label":"搜索", + "submit_css":"primary", + "fields":[ + { + "name": "project_id", + "uitype": "str", + "placeholder": "项目ID", + "cwidth": 15 + }, + { + "name": "status_input", + "uitype": "code", + "placeholder": "匹配状态", + "cwidth": 15, + "valueField": "status_input", + "textField": "status_input_text", + "params": { + "dbname": "{{get_module_dbname('pipeline_bidding')}}", + "table": "appcodes_kv", + "tblvalue": "k", + "tbltext": "v", + "valueField": "status_input", + "textField": "status_input_text", + "cond": "parentid='bid_qual_match_status'" + }, + "dataurl": "{{entire_url('/appbase/get_code.dspy')}}?prepend_all=1" + } +] + }, + + + + + + "page_rows":160, + "cache_limit":5 + } + + ,"binds":[] + +}] +} \ No newline at end of file diff --git a/wwwroot/bid_qualifications/update_bid_qualifications.dspy b/wwwroot/bid_qualifications/update_bid_qualifications.dspy new file mode 100644 index 0000000..c71db5a --- /dev/null +++ b/wwwroot/bid_qualifications/update_bid_qualifications.dspy @@ -0,0 +1,44 @@ + +ns = params_kw.copy() +for k,v in list(ns.items()): + if v == 'NaN' or v == 'null' or v == '' or v is None: + if k != 'id': + del ns[k] + else: + ns[k] = None if v in ('NaN', 'null') else v + + + + + +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + + tblfields = json.loads('[{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"}, {"name": "project_id", "title": "项目ID", "type": "str", "length": 32, "nullable": "no"}, {"name": "qual_name", "title": "资质名称", "type": "str", "length": 255, "nullable": "no"}, {"name": "requirement", "title": "资质要求原文", "type": "text"}, {"name": "is_mandatory", "title": "是否必需", "type": "str", "length": 1, "nullable": "no", "default": "1"}, {"name": "owner", "title": "提供方", "type": "str", "length": 16, "nullable": "no", "default": "kb"}, {"name": "kb_doc_id", "title": "知识库文档ID", "type": "str", "length": 32}, {"name": "match_status", "title": "匹配状态", "type": "str", "length": 16, "nullable": "no", "default": "pending"}, {"name": "file_path", "title": "文档路径", "type": "str", "length": 500}, {"name": "note", "title": "说明", "type": "str", "length": 500}, {"name": "human_task_id", "title": "人类任务ID", "type": "str", "length": 32}, {"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"}, {"name": "updated_at", "title": "更新时间", "type": "timestamp"}]') + tblfname = [ f['name'] for f in tblfields ] + other_keys = [ k for k in ns.keys() if k in tblfname and k != 'id' ] + if len(other_keys) > 0: + r = await sor.U('bid_qualifications', ns) + debug('update success'); + return { + "widgettype":"Message", + "options":{ + "title":"Update Success", + "cwidth":16, + "cheight":9, + "timeout":3, + "message":"ok" + } + } + +return { + "widgettype":"Error", + "options":{ + "title":"Update Error", + "cwidth":16, + "cheight":9, + "timeout":3, + "message":"failed" + } +} \ No newline at end of file diff --git a/wwwroot/bid_reviews/add_bid_reviews.dspy b/wwwroot/bid_reviews/add_bid_reviews.dspy new file mode 100644 index 0000000..ab22ff4 --- /dev/null +++ b/wwwroot/bid_reviews/add_bid_reviews.dspy @@ -0,0 +1,41 @@ + +ns = params_kw.copy() +for k,v in ns.items(): + if v == 'NaN' or v == 'null': + ns[k] = None +id = params_kw.id +if not id or len(id) > 32: + id = uuid() +ns['id'] = id + + + + +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + r = await sor.C('bid_reviews', ns.copy()) + _new_rows = await sor.sqlExe("SELECT * FROM bid_reviews WHERE id = ${id}$", {'id': id}) + _user_data = _new_rows[0] if _new_rows else ns + return { + "widgettype":"Message", + "options":{ + "cwidth":16, + "cheight":9, + "title":"Add Success", + "timeout":3, + "message":"ok", + "user_data": _user_data + } + } + +return { + "widgettype":"Error", + "options":{ + "title":"Add Error", + "cwidth":16, + "cheight":9, + "timeout":3, + "message":"failed" + } +} \ No newline at end of file diff --git a/wwwroot/bid_reviews/delete_bid_reviews.dspy b/wwwroot/bid_reviews/delete_bid_reviews.dspy new file mode 100644 index 0000000..43ba8e4 --- /dev/null +++ b/wwwroot/bid_reviews/delete_bid_reviews.dspy @@ -0,0 +1,33 @@ + +ns = { + 'id':params_kw['id'], +} + + +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + r = await sor.D('bid_reviews', ns) + debug('delete success'); + return { + "widgettype":"Message", + "options":{ + "title":"Delete Success", + "timeout":3, + "cwidth":16, + "cheight":9, + "message":"ok" + } + } + +debug('Delete failed'); +return { + "widgettype":"Error", + "options":{ + "title":"Delete Error", + "timeout":3, + "cwidth":16, + "cheight":9, + "message":"failed" + } +} \ No newline at end of file diff --git a/wwwroot/bid_reviews/get_bid_reviews.dspy b/wwwroot/bid_reviews/get_bid_reviews.dspy new file mode 100644 index 0000000..a6fbb7d --- /dev/null +++ b/wwwroot/bid_reviews/get_bid_reviews.dspy @@ -0,0 +1,49 @@ + +ns = params_kw.copy() + + +debug(f'get_bid_reviews.dspy:{ns=}') +if not ns.get('page'): + ns['page'] = 1 +if not ns.get('sort'): + + + ns['sort'] = json.loads('["created_at desc"]') + + + +sql = '''select a.*, b.scope_text +from (select * from bid_reviews where 1=1 [[filterstr]]) a left join (select k as scope, + v as scope_text from appcodes_kv where parentid='bid_review_scope') b on a.scope = b.scope''' + + +filterjson = json.loads('{"AND": [{"field": "project_id", "op": "=", "var": "project_id"}, {"field": "scope", "op": "=", "var": "scope_input"}]}') + + +filterdic = ns.copy() +filterdic['filterstr'] = '' +filterdic['userorgid'] = '${userorgid}$' +filterdic['userid'] = '${userid}$' +if filterjson: + dbf = DBFilter(filterjson) + conds = dbf.gen(ns) + if conds: + ns.update(dbf.consts) + conds = f' and {conds}' + filterdic['filterstr'] = conds +ac = ArgsConvert('[[', ']]') +vars = ac.findAllVariables(sql) +NameSpace = {v:'${' + v + '}$' for v in vars if v != 'filterstr' } +filterdic.update(NameSpace) +sql = ac.convert(sql, filterdic) + +debug(f'{sql=}') +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + r = await sor.sqlPaging(sql, ns) + return r +return { + "total":0, + "rows":[] +} \ No newline at end of file diff --git a/wwwroot/bid_reviews/index.ui b/wwwroot/bid_reviews/index.ui new file mode 100644 index 0000000..b3b3be3 --- /dev/null +++ b/wwwroot/bid_reviews/index.ui @@ -0,0 +1,338 @@ + +{ + "widgettype":"VBox", + "options":{"height":"100%","width":"100%"}, + "subwidgets":[ +{ + "id":"bid_reviews_tbl", + "widgettype":"Tabular", + "options":{ + "width":"100%", + "height":"100%", + + + "title":"标书评审记录", + + + + + "toolbar":{ + "tools": [ + { + "name": "filter", + "label": "搜索" + }, + { + "selected_row": true, + "name": "bid_scores", + "icon": "{{entire_url('/imgs/bid_scores.svg')}}", + "label": "评分明细" + } + ] +}, + + "css":"card", + + + "editable":{ + + "new_data_url": "{{entire_url('add_bid_reviews.dspy')}}", + + + "delete_data_url": "{{entire_url('delete_bid_reviews.dspy')}}", + + + "update_data_url": "{{entire_url('update_bid_reviews.dspy')}}" + + + }, + + + "data_url":"{{entire_url('./get_bid_reviews.dspy')}}", + + "data_method":"GET", + "data_params":{{json.dumps(params_kw, indent=4, ensure_ascii=False)}}, + "row_options":{ + + + + "browserfields": { + "exclouded": [ + "detail" + ] +}, + + + "editexclouded":[ + "id", + "created_at", + "updated_at", + "task_id" +], + + "fields":[ + { + "name": "id", + "title": "主键ID", + "type": "str", + "length": 32, + "nullable": "no", + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "主键ID" + }, + { + "name": "project_id", + "title": "项目ID", + "type": "str", + "length": 32, + "nullable": "no", + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "项目ID" + }, + { + "name": "scope", + "title": "评审范围", + "type": "str", + "length": 16, + "nullable": "no", + "default": "chapter", + "label": "评审范围", + "uitype": "code", + "valueField": "scope", + "textField": "scope_text", + "params": { + "dbname": "{{get_module_dbname('pipeline_bidding')}}", + "table": "appcodes_kv", + "tblvalue": "k", + "tbltext": "v", + "valueField": "scope", + "textField": "scope_text", + "cond": "parentid='bid_review_scope'" + }, + "dataurl": "{{entire_url('/appbase/get_code.dspy')}}" + }, + { + "name": "chapter_id", + "title": "章节ID", + "type": "str", + "length": 32, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "章节ID" + }, + { + "name": "document_id", + "title": "标书ID", + "type": "str", + "length": 32, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "标书ID" + }, + { + "name": "round", + "title": "评审轮次", + "type": "int", + "nullable": "no", + "default": "1", + "length": 0, + "uitype": "int", + "datatype": "int", + "label": "评审轮次" + }, + { + "name": "reviewer", + "title": "评审方", + "type": "str", + "length": 64, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "评审方" + }, + { + "name": "review_type", + "title": "评审类型", + "type": "str", + "length": 16, + "default": "agent", + "cwidth": 16, + "uitype": "str", + "datatype": "str", + "label": "评审类型" + }, + { + "name": "score", + "title": "得分", + "type": "float", + "length": 8, + "dec": 2, + "cwidth": 8, + "uitype": "float", + "datatype": "float", + "label": "得分" + }, + { + "name": "max_score", + "title": "满分", + "type": "float", + "length": 8, + "dec": 2, + "cwidth": 8, + "uitype": "float", + "datatype": "float", + "label": "满分" + }, + { + "name": "pass_ratio", + "title": "通过门限", + "type": "float", + "length": 5, + "dec": 4, + "cwidth": 5, + "uitype": "float", + "datatype": "float", + "label": "通过门限" + }, + { + "name": "passed", + "title": "是否通过", + "type": "str", + "length": 1, + "nullable": "no", + "default": "0", + "cwidth": 4, + "uitype": "str", + "datatype": "str", + "label": "是否通过" + }, + { + "name": "comments", + "title": "改进意见", + "type": "text", + "length": 0, + "uitype": "text", + "datatype": "text", + "label": "改进意见" + }, + { + "name": "detail", + "title": "评审明细(JSON)", + "type": "text", + "length": 0, + "uitype": "text", + "datatype": "text", + "label": "评审明细(JSON)" + }, + { + "name": "task_id", + "title": "关联任务ID", + "type": "str", + "length": 32, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "关联任务ID" + }, + { + "name": "created_at", + "title": "创建时间", + "type": "timestamp", + "nullable": "no", + "length": 0, + "uitype": "str", + "datatype": "timestamp", + "label": "创建时间" + }, + { + "name": "updated_at", + "title": "更新时间", + "type": "timestamp", + "length": 0, + "uitype": "str", + "datatype": "timestamp", + "label": "更新时间" + } +] + }, + + + + "search_form":{ + "css":"card", + "padding":"8px", + "show_label":false, + "submit_label":"搜索", + "submit_css":"primary", + "fields":[ + { + "name": "project_id", + "uitype": "str", + "placeholder": "项目ID", + "cwidth": 15 + }, + { + "name": "scope_input", + "uitype": "code", + "placeholder": "评审范围", + "cwidth": 15, + "valueField": "scope_input", + "textField": "scope_input_text", + "params": { + "dbname": "{{get_module_dbname('pipeline_bidding')}}", + "table": "appcodes_kv", + "tblvalue": "k", + "tbltext": "v", + "valueField": "scope_input", + "textField": "scope_input_text", + "cond": "parentid='bid_review_scope'" + }, + "dataurl": "{{entire_url('/appbase/get_code.dspy')}}?prepend_all=1" + } +] + }, + + + + + + "page_rows":160, + "cache_limit":5 + } + + ,"binds":[ + { + "wid": "self", + "event": "bid_scores", + "actiontype": "urlwidget", + "target": "PopupWindow", + "popup_options": { + "title": "评分明细", + "icon": "{{entire_url('/appbase/show_icon.dspy')}}?id=bid_scores", + "resizable": true, + "height": "70%", + "width": "70%" + }, + "params_mapping": { + "mapping": { + "id": "review_id", + "referer_widget": "referer_widget" + }, + "need_other": false + }, + "options": { + "method": "POST", + "params": {}, + "url": "{{entire_url('../bid_scores?review_id=${id}')}}" + } + } +] + +}] +} \ No newline at end of file diff --git a/wwwroot/bid_reviews/update_bid_reviews.dspy b/wwwroot/bid_reviews/update_bid_reviews.dspy new file mode 100644 index 0000000..83b25c7 --- /dev/null +++ b/wwwroot/bid_reviews/update_bid_reviews.dspy @@ -0,0 +1,44 @@ + +ns = params_kw.copy() +for k,v in list(ns.items()): + if v == 'NaN' or v == 'null' or v == '' or v is None: + if k != 'id': + del ns[k] + else: + ns[k] = None if v in ('NaN', 'null') else v + + + + + +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + + tblfields = json.loads('[{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"}, {"name": "project_id", "title": "项目ID", "type": "str", "length": 32, "nullable": "no"}, {"name": "scope", "title": "评审范围", "type": "str", "length": 16, "nullable": "no", "default": "chapter"}, {"name": "chapter_id", "title": "章节ID", "type": "str", "length": 32}, {"name": "document_id", "title": "标书ID", "type": "str", "length": 32}, {"name": "round", "title": "评审轮次", "type": "int", "nullable": "no", "default": "1"}, {"name": "reviewer", "title": "评审方", "type": "str", "length": 64}, {"name": "review_type", "title": "评审类型", "type": "str", "length": 16, "default": "agent"}, {"name": "score", "title": "得分", "type": "float", "length": 8, "dec": 2}, {"name": "max_score", "title": "满分", "type": "float", "length": 8, "dec": 2}, {"name": "pass_ratio", "title": "通过门限", "type": "float", "length": 5, "dec": 4}, {"name": "passed", "title": "是否通过", "type": "str", "length": 1, "nullable": "no", "default": "0"}, {"name": "comments", "title": "改进意见", "type": "text"}, {"name": "detail", "title": "评审明细(JSON)", "type": "text"}, {"name": "task_id", "title": "关联任务ID", "type": "str", "length": 32}, {"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"}, {"name": "updated_at", "title": "更新时间", "type": "timestamp"}]') + tblfname = [ f['name'] for f in tblfields ] + other_keys = [ k for k in ns.keys() if k in tblfname and k != 'id' ] + if len(other_keys) > 0: + r = await sor.U('bid_reviews', ns) + debug('update success'); + return { + "widgettype":"Message", + "options":{ + "title":"Update Success", + "cwidth":16, + "cheight":9, + "timeout":3, + "message":"ok" + } + } + +return { + "widgettype":"Error", + "options":{ + "title":"Update Error", + "cwidth":16, + "cheight":9, + "timeout":3, + "message":"failed" + } +} \ No newline at end of file diff --git a/wwwroot/bid_scores/add_bid_scores.dspy b/wwwroot/bid_scores/add_bid_scores.dspy new file mode 100644 index 0000000..76b2af1 --- /dev/null +++ b/wwwroot/bid_scores/add_bid_scores.dspy @@ -0,0 +1,41 @@ + +ns = params_kw.copy() +for k,v in ns.items(): + if v == 'NaN' or v == 'null': + ns[k] = None +id = params_kw.id +if not id or len(id) > 32: + id = uuid() +ns['id'] = id + + + + +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + r = await sor.C('bid_scores', ns.copy()) + _new_rows = await sor.sqlExe("SELECT * FROM bid_scores WHERE id = ${id}$", {'id': id}) + _user_data = _new_rows[0] if _new_rows else ns + return { + "widgettype":"Message", + "options":{ + "cwidth":16, + "cheight":9, + "title":"Add Success", + "timeout":3, + "message":"ok", + "user_data": _user_data + } + } + +return { + "widgettype":"Error", + "options":{ + "title":"Add Error", + "cwidth":16, + "cheight":9, + "timeout":3, + "message":"failed" + } +} \ No newline at end of file diff --git a/wwwroot/bid_scores/delete_bid_scores.dspy b/wwwroot/bid_scores/delete_bid_scores.dspy new file mode 100644 index 0000000..98f132a --- /dev/null +++ b/wwwroot/bid_scores/delete_bid_scores.dspy @@ -0,0 +1,33 @@ + +ns = { + 'id':params_kw['id'], +} + + +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + r = await sor.D('bid_scores', ns) + debug('delete success'); + return { + "widgettype":"Message", + "options":{ + "title":"Delete Success", + "timeout":3, + "cwidth":16, + "cheight":9, + "message":"ok" + } + } + +debug('Delete failed'); +return { + "widgettype":"Error", + "options":{ + "title":"Delete Error", + "timeout":3, + "cwidth":16, + "cheight":9, + "message":"failed" + } +} \ No newline at end of file diff --git a/wwwroot/bid_scores/get_bid_scores.dspy b/wwwroot/bid_scores/get_bid_scores.dspy new file mode 100644 index 0000000..8067083 --- /dev/null +++ b/wwwroot/bid_scores/get_bid_scores.dspy @@ -0,0 +1,47 @@ + +ns = params_kw.copy() + + +debug(f'get_bid_scores.dspy:{ns=}') +if not ns.get('page'): + ns['page'] = 1 +if not ns.get('sort'): + + + ns['sort'] = json.loads('["created_at"]') + + + +sql = '''select * from bid_scores where 1=1 [[filterstr]]''' + + +filterjson = json.loads('{"AND": [{"field": "review_id", "op": "=", "var": "review_id"}, {"field": "project_id", "op": "=", "var": "project_id"}]}') + + +filterdic = ns.copy() +filterdic['filterstr'] = '' +filterdic['userorgid'] = '${userorgid}$' +filterdic['userid'] = '${userid}$' +if filterjson: + dbf = DBFilter(filterjson) + conds = dbf.gen(ns) + if conds: + ns.update(dbf.consts) + conds = f' and {conds}' + filterdic['filterstr'] = conds +ac = ArgsConvert('[[', ']]') +vars = ac.findAllVariables(sql) +NameSpace = {v:'${' + v + '}$' for v in vars if v != 'filterstr' } +filterdic.update(NameSpace) +sql = ac.convert(sql, filterdic) + +debug(f'{sql=}') +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + r = await sor.sqlPaging(sql, ns) + return r +return { + "total":0, + "rows":[] +} \ No newline at end of file diff --git a/wwwroot/bid_scores/index.ui b/wwwroot/bid_scores/index.ui new file mode 100644 index 0000000..46ed367 --- /dev/null +++ b/wwwroot/bid_scores/index.ui @@ -0,0 +1,219 @@ + +{ + "widgettype":"VBox", + "options":{"height":"100%","width":"100%"}, + "subwidgets":[ +{ + "id":"bid_scores_tbl", + "widgettype":"Tabular", + "options":{ + "width":"100%", + "height":"100%", + + + "title":"标书评分明细", + + + + + "toolbar":{ + "tools": [] +}, + + "css":"card", + + + "editable":{ + + "new_data_url": "{{entire_url('add_bid_scores.dspy')}}", + + + "delete_data_url": "{{entire_url('delete_bid_scores.dspy')}}", + + + "update_data_url": "{{entire_url('update_bid_scores.dspy')}}" + + + }, + + + "data_url":"{{entire_url('./get_bid_scores.dspy')}}", + + "data_method":"GET", + "data_params":{{json.dumps(params_kw, indent=4, ensure_ascii=False)}}, + "row_options":{ + + + + "browserfields": { + "exclouded": [] +}, + + + "editexclouded":[ + "id", + "created_at", + "updated_at" +], + + "fields":[ + { + "name": "id", + "title": "主键ID", + "type": "str", + "length": 32, + "nullable": "no", + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "主键ID" + }, + { + "name": "project_id", + "title": "项目ID", + "type": "str", + "length": 32, + "nullable": "no", + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "项目ID" + }, + { + "name": "review_id", + "title": "评审记录ID", + "type": "str", + "length": 32, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "评审记录ID" + }, + { + "name": "scoring_item_id", + "title": "评分项ID", + "type": "str", + "length": 32, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "评分项ID" + }, + { + "name": "item_name", + "title": "评分项名称", + "type": "str", + "length": 255, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "评分项名称" + }, + { + "name": "score", + "title": "得分", + "type": "float", + "length": 8, + "dec": 2, + "cwidth": 8, + "uitype": "float", + "datatype": "float", + "label": "得分" + }, + { + "name": "max_score", + "title": "满分", + "type": "float", + "length": 8, + "dec": 2, + "cwidth": 8, + "uitype": "float", + "datatype": "float", + "label": "满分" + }, + { + "name": "reason", + "title": "评分理由", + "type": "text", + "length": 0, + "uitype": "text", + "datatype": "text", + "label": "评分理由" + }, + { + "name": "improvement", + "title": "改进意见", + "type": "text", + "length": 0, + "uitype": "text", + "datatype": "text", + "label": "改进意见" + }, + { + "name": "target_chapter_id", + "title": "待改章节ID", + "type": "str", + "length": 32, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "待改章节ID" + }, + { + "name": "created_at", + "title": "创建时间", + "type": "timestamp", + "nullable": "no", + "length": 0, + "uitype": "str", + "datatype": "timestamp", + "label": "创建时间" + }, + { + "name": "updated_at", + "title": "更新时间", + "type": "timestamp", + "length": 0, + "uitype": "str", + "datatype": "timestamp", + "label": "更新时间" + } +] + }, + + + + "search_form":{ + "css":"card", + "padding":"8px", + "show_label":false, + "submit_label":"搜索", + "submit_css":"primary", + "fields":[ + { + "name": "review_id", + "uitype": "str", + "placeholder": "评审记录ID", + "cwidth": 15 + }, + { + "name": "project_id", + "uitype": "str", + "placeholder": "项目ID", + "cwidth": 15 + } +] + }, + + + + + + "page_rows":160, + "cache_limit":5 + } + + ,"binds":[] + +}] +} \ No newline at end of file diff --git a/wwwroot/bid_scores/update_bid_scores.dspy b/wwwroot/bid_scores/update_bid_scores.dspy new file mode 100644 index 0000000..841b3fa --- /dev/null +++ b/wwwroot/bid_scores/update_bid_scores.dspy @@ -0,0 +1,44 @@ + +ns = params_kw.copy() +for k,v in list(ns.items()): + if v == 'NaN' or v == 'null' or v == '' or v is None: + if k != 'id': + del ns[k] + else: + ns[k] = None if v in ('NaN', 'null') else v + + + + + +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + + tblfields = json.loads('[{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"}, {"name": "project_id", "title": "项目ID", "type": "str", "length": 32, "nullable": "no"}, {"name": "review_id", "title": "评审记录ID", "type": "str", "length": 32}, {"name": "scoring_item_id", "title": "评分项ID", "type": "str", "length": 32}, {"name": "item_name", "title": "评分项名称", "type": "str", "length": 255}, {"name": "score", "title": "得分", "type": "float", "length": 8, "dec": 2}, {"name": "max_score", "title": "满分", "type": "float", "length": 8, "dec": 2}, {"name": "reason", "title": "评分理由", "type": "text"}, {"name": "improvement", "title": "改进意见", "type": "text"}, {"name": "target_chapter_id", "title": "待改章节ID", "type": "str", "length": 32}, {"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"}, {"name": "updated_at", "title": "更新时间", "type": "timestamp"}]') + tblfname = [ f['name'] for f in tblfields ] + other_keys = [ k for k in ns.keys() if k in tblfname and k != 'id' ] + if len(other_keys) > 0: + r = await sor.U('bid_scores', ns) + debug('update success'); + return { + "widgettype":"Message", + "options":{ + "title":"Update Success", + "cwidth":16, + "cheight":9, + "timeout":3, + "message":"ok" + } + } + +return { + "widgettype":"Error", + "options":{ + "title":"Update Error", + "cwidth":16, + "cheight":9, + "timeout":3, + "message":"failed" + } +} \ No newline at end of file diff --git a/wwwroot/bid_scoring_items/add_bid_scoring_items.dspy b/wwwroot/bid_scoring_items/add_bid_scoring_items.dspy new file mode 100644 index 0000000..445d3a8 --- /dev/null +++ b/wwwroot/bid_scoring_items/add_bid_scoring_items.dspy @@ -0,0 +1,41 @@ + +ns = params_kw.copy() +for k,v in ns.items(): + if v == 'NaN' or v == 'null': + ns[k] = None +id = params_kw.id +if not id or len(id) > 32: + id = uuid() +ns['id'] = id + + + + +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + r = await sor.C('bid_scoring_items', ns.copy()) + _new_rows = await sor.sqlExe("SELECT * FROM bid_scoring_items WHERE id = ${id}$", {'id': id}) + _user_data = _new_rows[0] if _new_rows else ns + return { + "widgettype":"Message", + "options":{ + "cwidth":16, + "cheight":9, + "title":"Add Success", + "timeout":3, + "message":"ok", + "user_data": _user_data + } + } + +return { + "widgettype":"Error", + "options":{ + "title":"Add Error", + "cwidth":16, + "cheight":9, + "timeout":3, + "message":"failed" + } +} \ No newline at end of file diff --git a/wwwroot/bid_scoring_items/delete_bid_scoring_items.dspy b/wwwroot/bid_scoring_items/delete_bid_scoring_items.dspy new file mode 100644 index 0000000..8587195 --- /dev/null +++ b/wwwroot/bid_scoring_items/delete_bid_scoring_items.dspy @@ -0,0 +1,33 @@ + +ns = { + 'id':params_kw['id'], +} + + +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + r = await sor.D('bid_scoring_items', ns) + debug('delete success'); + return { + "widgettype":"Message", + "options":{ + "title":"Delete Success", + "timeout":3, + "cwidth":16, + "cheight":9, + "message":"ok" + } + } + +debug('Delete failed'); +return { + "widgettype":"Error", + "options":{ + "title":"Delete Error", + "timeout":3, + "cwidth":16, + "cheight":9, + "message":"failed" + } +} \ No newline at end of file diff --git a/wwwroot/bid_scoring_items/get_bid_scoring_items.dspy b/wwwroot/bid_scoring_items/get_bid_scoring_items.dspy new file mode 100644 index 0000000..64c776b --- /dev/null +++ b/wwwroot/bid_scoring_items/get_bid_scoring_items.dspy @@ -0,0 +1,49 @@ + +ns = params_kw.copy() + + +debug(f'get_bid_scoring_items.dspy:{ns=}') +if not ns.get('page'): + ns['page'] = 1 +if not ns.get('sort'): + + + ns['sort'] = json.loads('["section", "order_no"]') + + + +sql = '''select a.*, b.section_text +from (select * from bid_scoring_items where 1=1 [[filterstr]]) a left join (select k as section, + v as section_text from appcodes_kv where parentid='bid_scoring_section') b on a.section = b.section''' + + +filterjson = json.loads('{"AND": [{"field": "project_id", "op": "=", "var": "project_id"}, {"field": "section", "op": "=", "var": "section_input"}, {"field": "item_name", "op": "LIKE", "var": "name_input"}]}') + + +filterdic = ns.copy() +filterdic['filterstr'] = '' +filterdic['userorgid'] = '${userorgid}$' +filterdic['userid'] = '${userid}$' +if filterjson: + dbf = DBFilter(filterjson) + conds = dbf.gen(ns) + if conds: + ns.update(dbf.consts) + conds = f' and {conds}' + filterdic['filterstr'] = conds +ac = ArgsConvert('[[', ']]') +vars = ac.findAllVariables(sql) +NameSpace = {v:'${' + v + '}$' for v in vars if v != 'filterstr' } +filterdic.update(NameSpace) +sql = ac.convert(sql, filterdic) + +debug(f'{sql=}') +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + r = await sor.sqlPaging(sql, ns) + return r +return { + "total":0, + "rows":[] +} \ No newline at end of file diff --git a/wwwroot/bid_scoring_items/index.ui b/wwwroot/bid_scoring_items/index.ui new file mode 100644 index 0000000..da8e599 --- /dev/null +++ b/wwwroot/bid_scoring_items/index.ui @@ -0,0 +1,275 @@ + +{ + "widgettype":"VBox", + "options":{"height":"100%","width":"100%"}, + "subwidgets":[ +{ + "id":"bid_scoring_items_tbl", + "widgettype":"Tabular", + "options":{ + "width":"100%", + "height":"100%", + + + "title":"评分项与得分规则", + + + + + "toolbar":{ + "tools": [] +}, + + "css":"card", + + + "editable":{ + + "new_data_url": "{{entire_url('add_bid_scoring_items.dspy')}}", + + + "delete_data_url": "{{entire_url('delete_bid_scoring_items.dspy')}}", + + + "update_data_url": "{{entire_url('update_bid_scoring_items.dspy')}}" + + + }, + + + "data_url":"{{entire_url('./get_bid_scoring_items.dspy')}}", + + "data_method":"GET", + "data_params":{{json.dumps(params_kw, indent=4, ensure_ascii=False)}}, + "row_options":{ + + + + "browserfields": { + "exclouded": [] +}, + + + "editexclouded":[ + "id", + "created_at", + "updated_at" +], + + "fields":[ + { + "name": "id", + "title": "主键ID", + "type": "str", + "length": 32, + "nullable": "no", + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "主键ID" + }, + { + "name": "project_id", + "title": "项目ID", + "type": "str", + "length": 32, + "nullable": "no", + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "项目ID" + }, + { + "name": "section", + "title": "评分部分", + "type": "str", + "length": 32, + "nullable": "no", + "default": "technical", + "label": "评分部分", + "uitype": "code", + "valueField": "section", + "textField": "section_text", + "params": { + "dbname": "{{get_module_dbname('pipeline_bidding')}}", + "table": "appcodes_kv", + "tblvalue": "k", + "tbltext": "v", + "valueField": "section", + "textField": "section_text", + "cond": "parentid='bid_scoring_section'" + }, + "dataurl": "{{entire_url('/appbase/get_code.dspy')}}" + }, + { + "name": "item_no", + "title": "评分项编号", + "type": "str", + "length": 32, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "评分项编号" + }, + { + "name": "item_name", + "title": "评分项名称", + "type": "str", + "length": 255, + "nullable": "no", + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "评分项名称" + }, + { + "name": "max_score", + "title": "满分", + "type": "float", + "length": 8, + "dec": 2, + "nullable": "no", + "default": "0", + "cwidth": 8, + "uitype": "float", + "datatype": "float", + "label": "满分" + }, + { + "name": "scoring_rule", + "title": "得分规则", + "type": "text", + "length": 0, + "uitype": "text", + "datatype": "text", + "label": "得分规则" + }, + { + "name": "is_veto", + "title": "否决项", + "type": "str", + "length": 1, + "nullable": "no", + "default": "0", + "cwidth": 4, + "uitype": "str", + "datatype": "str", + "label": "否决项" + }, + { + "name": "weight", + "title": "权重", + "type": "float", + "length": 8, + "dec": 4, + "cwidth": 8, + "uitype": "float", + "datatype": "float", + "label": "权重" + }, + { + "name": "source_ref", + "title": "招标文件出处", + "type": "str", + "length": 500, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "招标文件出处" + }, + { + "name": "target_chapter_no", + "title": "对应章节号", + "type": "str", + "length": 32, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "对应章节号" + }, + { + "name": "order_no", + "title": "排序", + "type": "int", + "default": "0", + "length": 0, + "uitype": "int", + "datatype": "int", + "label": "排序" + }, + { + "name": "created_at", + "title": "创建时间", + "type": "timestamp", + "nullable": "no", + "length": 0, + "uitype": "str", + "datatype": "timestamp", + "label": "创建时间" + }, + { + "name": "updated_at", + "title": "更新时间", + "type": "timestamp", + "length": 0, + "uitype": "str", + "datatype": "timestamp", + "label": "更新时间" + } +] + }, + + + + "search_form":{ + "css":"card", + "padding":"8px", + "show_label":false, + "submit_label":"搜索", + "submit_css":"primary", + "fields":[ + { + "name": "project_id", + "uitype": "str", + "placeholder": "项目ID", + "cwidth": 15 + }, + { + "name": "section_input", + "uitype": "code", + "placeholder": "评分部分", + "cwidth": 15, + "valueField": "section_input", + "textField": "section_input_text", + "params": { + "dbname": "{{get_module_dbname('pipeline_bidding')}}", + "table": "appcodes_kv", + "tblvalue": "k", + "tbltext": "v", + "valueField": "section_input", + "textField": "section_input_text", + "cond": "parentid='bid_scoring_section'" + }, + "dataurl": "{{entire_url('/appbase/get_code.dspy')}}?prepend_all=1" + }, + { + "name": "name_input", + "uitype": "str", + "placeholder": "评分项名称", + "cwidth": 15 + } +] + }, + + + + + + "page_rows":160, + "cache_limit":5 + } + + ,"binds":[] + +}] +} \ No newline at end of file diff --git a/wwwroot/bid_scoring_items/update_bid_scoring_items.dspy b/wwwroot/bid_scoring_items/update_bid_scoring_items.dspy new file mode 100644 index 0000000..07118f9 --- /dev/null +++ b/wwwroot/bid_scoring_items/update_bid_scoring_items.dspy @@ -0,0 +1,44 @@ + +ns = params_kw.copy() +for k,v in list(ns.items()): + if v == 'NaN' or v == 'null' or v == '' or v is None: + if k != 'id': + del ns[k] + else: + ns[k] = None if v in ('NaN', 'null') else v + + + + + +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + + tblfields = json.loads('[{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"}, {"name": "project_id", "title": "项目ID", "type": "str", "length": 32, "nullable": "no"}, {"name": "section", "title": "评分部分", "type": "str", "length": 32, "nullable": "no", "default": "technical"}, {"name": "item_no", "title": "评分项编号", "type": "str", "length": 32}, {"name": "item_name", "title": "评分项名称", "type": "str", "length": 255, "nullable": "no"}, {"name": "max_score", "title": "满分", "type": "float", "length": 8, "dec": 2, "nullable": "no", "default": "0"}, {"name": "scoring_rule", "title": "得分规则", "type": "text"}, {"name": "is_veto", "title": "否决项", "type": "str", "length": 1, "nullable": "no", "default": "0"}, {"name": "weight", "title": "权重", "type": "float", "length": 8, "dec": 4}, {"name": "source_ref", "title": "招标文件出处", "type": "str", "length": 500}, {"name": "target_chapter_no", "title": "对应章节号", "type": "str", "length": 32}, {"name": "order_no", "title": "排序", "type": "int", "default": "0"}, {"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"}, {"name": "updated_at", "title": "更新时间", "type": "timestamp"}]') + tblfname = [ f['name'] for f in tblfields ] + other_keys = [ k for k in ns.keys() if k in tblfname and k != 'id' ] + if len(other_keys) > 0: + r = await sor.U('bid_scoring_items', ns) + debug('update success'); + return { + "widgettype":"Message", + "options":{ + "title":"Update Success", + "cwidth":16, + "cheight":9, + "timeout":3, + "message":"ok" + } + } + +return { + "widgettype":"Error", + "options":{ + "title":"Update Error", + "cwidth":16, + "cheight":9, + "timeout":3, + "message":"failed" + } +} \ No newline at end of file diff --git a/wwwroot/bid_tender_files/add_bid_tender_files.dspy b/wwwroot/bid_tender_files/add_bid_tender_files.dspy new file mode 100644 index 0000000..9250188 --- /dev/null +++ b/wwwroot/bid_tender_files/add_bid_tender_files.dspy @@ -0,0 +1,41 @@ + +ns = params_kw.copy() +for k,v in ns.items(): + if v == 'NaN' or v == 'null': + ns[k] = None +id = params_kw.id +if not id or len(id) > 32: + id = uuid() +ns['id'] = id + + + + +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + r = await sor.C('bid_tender_files', ns.copy()) + _new_rows = await sor.sqlExe("SELECT * FROM bid_tender_files WHERE id = ${id}$", {'id': id}) + _user_data = _new_rows[0] if _new_rows else ns + return { + "widgettype":"Message", + "options":{ + "cwidth":16, + "cheight":9, + "title":"Add Success", + "timeout":3, + "message":"ok", + "user_data": _user_data + } + } + +return { + "widgettype":"Error", + "options":{ + "title":"Add Error", + "cwidth":16, + "cheight":9, + "timeout":3, + "message":"failed" + } +} \ No newline at end of file diff --git a/wwwroot/bid_tender_files/delete_bid_tender_files.dspy b/wwwroot/bid_tender_files/delete_bid_tender_files.dspy new file mode 100644 index 0000000..8257358 --- /dev/null +++ b/wwwroot/bid_tender_files/delete_bid_tender_files.dspy @@ -0,0 +1,33 @@ + +ns = { + 'id':params_kw['id'], +} + + +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + r = await sor.D('bid_tender_files', ns) + debug('delete success'); + return { + "widgettype":"Message", + "options":{ + "title":"Delete Success", + "timeout":3, + "cwidth":16, + "cheight":9, + "message":"ok" + } + } + +debug('Delete failed'); +return { + "widgettype":"Error", + "options":{ + "title":"Delete Error", + "timeout":3, + "cwidth":16, + "cheight":9, + "message":"failed" + } +} \ No newline at end of file diff --git a/wwwroot/bid_tender_files/get_bid_tender_files.dspy b/wwwroot/bid_tender_files/get_bid_tender_files.dspy new file mode 100644 index 0000000..2c69b56 --- /dev/null +++ b/wwwroot/bid_tender_files/get_bid_tender_files.dspy @@ -0,0 +1,49 @@ + +ns = params_kw.copy() + + +debug(f'get_bid_tender_files.dspy:{ns=}') +if not ns.get('page'): + ns['page'] = 1 +if not ns.get('sort'): + + + ns['sort'] = json.loads('["created_at desc"]') + + + +sql = '''select a.*, b.file_type_text +from (select * from bid_tender_files where 1=1 [[filterstr]]) a left join (select k as file_type, + v as file_type_text from appcodes_kv where parentid='bid_file_type') b on a.file_type = b.file_type''' + + +filterjson = json.loads('{"AND": [{"field": "project_id", "op": "=", "var": "project_id"}, {"field": "extract_status", "op": "=", "var": "status_input"}]}') + + +filterdic = ns.copy() +filterdic['filterstr'] = '' +filterdic['userorgid'] = '${userorgid}$' +filterdic['userid'] = '${userid}$' +if filterjson: + dbf = DBFilter(filterjson) + conds = dbf.gen(ns) + if conds: + ns.update(dbf.consts) + conds = f' and {conds}' + filterdic['filterstr'] = conds +ac = ArgsConvert('[[', ']]') +vars = ac.findAllVariables(sql) +NameSpace = {v:'${' + v + '}$' for v in vars if v != 'filterstr' } +filterdic.update(NameSpace) +sql = ac.convert(sql, filterdic) + +debug(f'{sql=}') +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + r = await sor.sqlPaging(sql, ns) + return r +return { + "total":0, + "rows":[] +} \ No newline at end of file diff --git a/wwwroot/bid_tender_files/index.ui b/wwwroot/bid_tender_files/index.ui new file mode 100644 index 0000000..81e8398 --- /dev/null +++ b/wwwroot/bid_tender_files/index.ui @@ -0,0 +1,246 @@ + +{ + "widgettype":"VBox", + "options":{"height":"100%","width":"100%"}, + "subwidgets":[ +{ + "id":"bid_tender_files_tbl", + "widgettype":"Tabular", + "options":{ + "width":"100%", + "height":"100%", + + + "title":"招标文件", + + + + + "toolbar":{ + "tools": [] +}, + + "css":"card", + + + "editable":{ + + "new_data_url": "{{entire_url('add_bid_tender_files.dspy')}}", + + + "delete_data_url": "{{entire_url('delete_bid_tender_files.dspy')}}", + + + "update_data_url": "{{entire_url('update_bid_tender_files.dspy')}}" + + + }, + + + "data_url":"{{entire_url('./get_bid_tender_files.dspy')}}", + + "data_method":"GET", + "data_params":{{json.dumps(params_kw, indent=4, ensure_ascii=False)}}, + "row_options":{ + + + + "browserfields": { + "exclouded": [ + "content_text" + ] +}, + + + "editexclouded":[ + "id", + "created_at", + "updated_at", + "content_text" +], + + "fields":[ + { + "name": "id", + "title": "主键ID", + "type": "str", + "length": 32, + "nullable": "no", + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "主键ID" + }, + { + "name": "project_id", + "title": "项目ID", + "type": "str", + "length": 32, + "nullable": "no", + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "项目ID" + }, + { + "name": "tender_id", + "title": "招标信息ID", + "type": "str", + "length": 32, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "招标信息ID" + }, + { + "name": "file_name", + "title": "文件名", + "type": "str", + "length": 255, + "nullable": "no", + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "文件名" + }, + { + "name": "file_path", + "title": "文件路径", + "type": "str", + "length": 500, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "文件路径" + }, + { + "name": "file_type", + "title": "文件类型", + "type": "str", + "length": 32, + "nullable": "no", + "default": "tender_doc", + "label": "文件类型", + "uitype": "code", + "valueField": "file_type", + "textField": "file_type_text", + "params": { + "dbname": "{{get_module_dbname('pipeline_bidding')}}", + "table": "appcodes_kv", + "tblvalue": "k", + "tbltext": "v", + "valueField": "file_type", + "textField": "file_type_text", + "cond": "parentid='bid_file_type'" + }, + "dataurl": "{{entire_url('/appbase/get_code.dspy')}}" + }, + { + "name": "file_size", + "title": "文件大小", + "type": "int", + "default": "0", + "length": 0, + "uitype": "int", + "datatype": "int", + "label": "文件大小" + }, + { + "name": "content_text", + "title": "正文提取", + "type": "text", + "length": 0, + "uitype": "text", + "datatype": "text", + "label": "正文提取" + }, + { + "name": "extract_status", + "title": "抽取状态", + "type": "str", + "length": 16, + "nullable": "no", + "default": "pending", + "cwidth": 16, + "uitype": "str", + "datatype": "str", + "label": "抽取状态" + }, + { + "name": "extract_note", + "title": "抽取说明", + "type": "str", + "length": 500, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "抽取说明" + }, + { + "name": "uploaded_by", + "title": "上传人", + "type": "str", + "length": 32, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "上传人" + }, + { + "name": "created_at", + "title": "创建时间", + "type": "timestamp", + "nullable": "no", + "length": 0, + "uitype": "str", + "datatype": "timestamp", + "label": "创建时间" + }, + { + "name": "updated_at", + "title": "更新时间", + "type": "timestamp", + "length": 0, + "uitype": "str", + "datatype": "timestamp", + "label": "更新时间" + } +] + }, + + + + "search_form":{ + "css":"card", + "padding":"8px", + "show_label":false, + "submit_label":"搜索", + "submit_css":"primary", + "fields":[ + { + "name": "project_id", + "uitype": "str", + "placeholder": "项目ID", + "cwidth": 15 + }, + { + "name": "status_input", + "uitype": "str", + "placeholder": "抽取状态", + "cwidth": 15 + } +] + }, + + + + + + "page_rows":160, + "cache_limit":5 + } + + ,"binds":[] + +}] +} \ No newline at end of file diff --git a/wwwroot/bid_tender_files/update_bid_tender_files.dspy b/wwwroot/bid_tender_files/update_bid_tender_files.dspy new file mode 100644 index 0000000..9c79819 --- /dev/null +++ b/wwwroot/bid_tender_files/update_bid_tender_files.dspy @@ -0,0 +1,44 @@ + +ns = params_kw.copy() +for k,v in list(ns.items()): + if v == 'NaN' or v == 'null' or v == '' or v is None: + if k != 'id': + del ns[k] + else: + ns[k] = None if v in ('NaN', 'null') else v + + + + + +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + + tblfields = json.loads('[{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"}, {"name": "project_id", "title": "项目ID", "type": "str", "length": 32, "nullable": "no"}, {"name": "tender_id", "title": "招标信息ID", "type": "str", "length": 32}, {"name": "file_name", "title": "文件名", "type": "str", "length": 255, "nullable": "no"}, {"name": "file_path", "title": "文件路径", "type": "str", "length": 500}, {"name": "file_type", "title": "文件类型", "type": "str", "length": 32, "nullable": "no", "default": "tender_doc"}, {"name": "file_size", "title": "文件大小", "type": "int", "default": "0"}, {"name": "content_text", "title": "正文提取", "type": "text"}, {"name": "extract_status", "title": "抽取状态", "type": "str", "length": 16, "nullable": "no", "default": "pending"}, {"name": "extract_note", "title": "抽取说明", "type": "str", "length": 500}, {"name": "uploaded_by", "title": "上传人", "type": "str", "length": 32}, {"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"}, {"name": "updated_at", "title": "更新时间", "type": "timestamp"}]') + tblfname = [ f['name'] for f in tblfields ] + other_keys = [ k for k in ns.keys() if k in tblfname and k != 'id' ] + if len(other_keys) > 0: + r = await sor.U('bid_tender_files', ns) + debug('update success'); + return { + "widgettype":"Message", + "options":{ + "title":"Update Success", + "cwidth":16, + "cheight":9, + "timeout":3, + "message":"ok" + } + } + +return { + "widgettype":"Error", + "options":{ + "title":"Update Error", + "cwidth":16, + "cheight":9, + "timeout":3, + "message":"failed" + } +} \ No newline at end of file diff --git a/wwwroot/bid_tenders/add_bid_tenders.dspy b/wwwroot/bid_tenders/add_bid_tenders.dspy new file mode 100644 index 0000000..b334e4f --- /dev/null +++ b/wwwroot/bid_tenders/add_bid_tenders.dspy @@ -0,0 +1,41 @@ + +ns = params_kw.copy() +for k,v in ns.items(): + if v == 'NaN' or v == 'null': + ns[k] = None +id = params_kw.id +if not id or len(id) > 32: + id = uuid() +ns['id'] = id + + + + +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + r = await sor.C('bid_tenders', ns.copy()) + _new_rows = await sor.sqlExe("SELECT * FROM bid_tenders WHERE id = ${id}$", {'id': id}) + _user_data = _new_rows[0] if _new_rows else ns + return { + "widgettype":"Message", + "options":{ + "cwidth":16, + "cheight":9, + "title":"Add Success", + "timeout":3, + "message":"ok", + "user_data": _user_data + } + } + +return { + "widgettype":"Error", + "options":{ + "title":"Add Error", + "cwidth":16, + "cheight":9, + "timeout":3, + "message":"failed" + } +} \ No newline at end of file diff --git a/wwwroot/bid_tenders/delete_bid_tenders.dspy b/wwwroot/bid_tenders/delete_bid_tenders.dspy new file mode 100644 index 0000000..19386bb --- /dev/null +++ b/wwwroot/bid_tenders/delete_bid_tenders.dspy @@ -0,0 +1,33 @@ + +ns = { + 'id':params_kw['id'], +} + + +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + r = await sor.D('bid_tenders', ns) + debug('delete success'); + return { + "widgettype":"Message", + "options":{ + "title":"Delete Success", + "timeout":3, + "cwidth":16, + "cheight":9, + "message":"ok" + } + } + +debug('Delete failed'); +return { + "widgettype":"Error", + "options":{ + "title":"Delete Error", + "timeout":3, + "cwidth":16, + "cheight":9, + "message":"failed" + } +} \ No newline at end of file diff --git a/wwwroot/bid_tenders/get_bid_tenders.dspy b/wwwroot/bid_tenders/get_bid_tenders.dspy new file mode 100644 index 0000000..a67d447 --- /dev/null +++ b/wwwroot/bid_tenders/get_bid_tenders.dspy @@ -0,0 +1,50 @@ + +ns = params_kw.copy() + + +debug(f'get_bid_tenders.dspy:{ns=}') +if not ns.get('page'): + ns['page'] = 1 +if not ns.get('sort'): + + + ns['sort'] = json.loads('["received_at desc"]') + + + +sql = '''select a.*, b.status_text, c.source_text +from (select * from bid_tenders where 1=1 [[filterstr]]) a left join (select k as status, + v as status_text from appcodes_kv where parentid='bid_tender_status') b on a.status = b.status left join (select k as source, + v as source_text from appcodes_kv where parentid='bid_tender_source') c on a.source = c.source''' + + +filterjson = json.loads('{"AND": [{"field": "title", "op": "LIKE", "var": "title_input"}, {"field": "status", "op": "=", "var": "status_input"}, {"field": "purchaser", "op": "LIKE", "var": "purchaser_input"}]}') + + +filterdic = ns.copy() +filterdic['filterstr'] = '' +filterdic['userorgid'] = '${userorgid}$' +filterdic['userid'] = '${userid}$' +if filterjson: + dbf = DBFilter(filterjson) + conds = dbf.gen(ns) + if conds: + ns.update(dbf.consts) + conds = f' and {conds}' + filterdic['filterstr'] = conds +ac = ArgsConvert('[[', ']]') +vars = ac.findAllVariables(sql) +NameSpace = {v:'${' + v + '}$' for v in vars if v != 'filterstr' } +filterdic.update(NameSpace) +sql = ac.convert(sql, filterdic) + +debug(f'{sql=}') +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + r = await sor.sqlPaging(sql, ns) + return r +return { + "total":0, + "rows":[] +} \ No newline at end of file diff --git a/wwwroot/bid_tenders/index.ui b/wwwroot/bid_tenders/index.ui new file mode 100644 index 0000000..9f3099d --- /dev/null +++ b/wwwroot/bid_tenders/index.ui @@ -0,0 +1,445 @@ + +{ + "widgettype":"VBox", + "options":{"height":"100%","width":"100%"}, + "subwidgets":[ +{ + "id":"bid_tenders_tbl", + "widgettype":"Tabular", + "options":{ + "width":"100%", + "height":"100%", + + + "title":"招标信息", + + + + + "toolbar":{ + "tools": [] +}, + + "css":"card", + + + "editable":{ + + "new_data_url": "{{entire_url('add_bid_tenders.dspy')}}", + + + "delete_data_url": "{{entire_url('delete_bid_tenders.dspy')}}", + + + "update_data_url": "{{entire_url('update_bid_tenders.dspy')}}" + + + }, + + + "data_url":"{{entire_url('./get_bid_tenders.dspy')}}", + + "data_method":"GET", + "data_params":{{json.dumps(params_kw, indent=4, ensure_ascii=False)}}, + "row_options":{ + + + + "browserfields": { + "exclouded": [ + "raw_content", + "attach_json", + "key_dates", + "org_id", + "account_id", + "mail_uid", + "risk_note" + ] +}, + + + "editexclouded":[ + "id", + "created_at", + "updated_at", + "approval_task_id", + "project_id" +], + + "fields":[ + { + "name": "id", + "title": "主键ID", + "type": "str", + "length": 32, + "nullable": "no", + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "主键ID" + }, + { + "name": "org_id", + "title": "机构ID", + "type": "str", + "length": 32, + "nullable": "no", + "default": "0", + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "机构ID" + }, + { + "name": "source", + "title": "信息来源", + "type": "str", + "length": 16, + "nullable": "no", + "default": "mail", + "label": "信息来源", + "uitype": "code", + "valueField": "source", + "textField": "source_text", + "params": { + "dbname": "{{get_module_dbname('pipeline_bidding')}}", + "table": "appcodes_kv", + "tblvalue": "k", + "tbltext": "v", + "valueField": "source", + "textField": "source_text", + "cond": "parentid='bid_tender_source'" + }, + "dataurl": "{{entire_url('/appbase/get_code.dspy')}}" + }, + { + "name": "account_id", + "title": "邮箱配置ID", + "type": "str", + "length": 32, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "邮箱配置ID" + }, + { + "name": "mail_uid", + "title": "邮件UID", + "type": "str", + "length": 64, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "邮件UID" + }, + { + "name": "mail_from", + "title": "发件人", + "type": "str", + "length": 255, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "发件人" + }, + { + "name": "mail_subject", + "title": "邮件主题", + "type": "str", + "length": 500, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "邮件主题" + }, + { + "name": "received_at", + "title": "收信时间", + "type": "timestamp", + "length": 0, + "uitype": "str", + "datatype": "timestamp", + "label": "收信时间" + }, + { + "name": "raw_content", + "title": "邮件原文", + "type": "text", + "length": 0, + "uitype": "text", + "datatype": "text", + "label": "邮件原文" + }, + { + "name": "attach_json", + "title": "附件清单(JSON)", + "type": "text", + "length": 0, + "uitype": "text", + "datatype": "text", + "label": "附件清单(JSON)" + }, + { + "name": "title", + "title": "项目名称", + "type": "str", + "length": 255, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "项目名称" + }, + { + "name": "purchaser", + "title": "招标人/采购人", + "type": "str", + "length": 255, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "招标人/采购人" + }, + { + "name": "tender_no", + "title": "招标编号", + "type": "str", + "length": 64, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "招标编号" + }, + { + "name": "industry", + "title": "行业", + "type": "str", + "length": 64, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "行业" + }, + { + "name": "summary", + "title": "内容摘要", + "type": "text", + "length": 0, + "uitype": "text", + "datatype": "text", + "label": "内容摘要" + }, + { + "name": "bid_start_at", + "title": "投标开始时间", + "type": "date", + "length": 0, + "uitype": "date", + "datatype": "date", + "label": "投标开始时间" + }, + { + "name": "bid_deadline", + "title": "投标截止时间", + "type": "date", + "length": 0, + "uitype": "date", + "datatype": "date", + "label": "投标截止时间" + }, + { + "name": "project_period", + "title": "项目周期", + "type": "str", + "length": 64, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "项目周期" + }, + { + "name": "budget_amount", + "title": "招标金额", + "type": "float", + "length": 18, + "dec": 2, + "cwidth": 18, + "uitype": "float", + "datatype": "float", + "label": "招标金额" + }, + { + "name": "deposit_amount", + "title": "投标保证金", + "type": "float", + "length": 18, + "dec": 2, + "cwidth": 18, + "uitype": "float", + "datatype": "float", + "label": "投标保证金" + }, + { + "name": "qualification_req", + "title": "资质要求摘要", + "type": "text", + "length": 0, + "uitype": "text", + "datatype": "text", + "label": "资质要求摘要" + }, + { + "name": "key_dates", + "title": "关键时间节点(JSON)", + "type": "text", + "length": 0, + "uitype": "text", + "datatype": "text", + "label": "关键时间节点(JSON)" + }, + { + "name": "risk_note", + "title": "风险提示", + "type": "text", + "length": 0, + "uitype": "text", + "datatype": "text", + "label": "风险提示" + }, + { + "name": "status", + "title": "状态", + "type": "str", + "length": 16, + "nullable": "no", + "default": "new", + "label": "状态", + "uitype": "code", + "valueField": "status", + "textField": "status_text", + "params": { + "dbname": "{{get_module_dbname('pipeline_bidding')}}", + "table": "appcodes_kv", + "tblvalue": "k", + "tbltext": "v", + "valueField": "status", + "textField": "status_text", + "cond": "parentid='bid_tender_status'" + }, + "dataurl": "{{entire_url('/appbase/get_code.dspy')}}" + }, + { + "name": "approval_task_id", + "title": "审批任务ID", + "type": "str", + "length": 32, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "审批任务ID" + }, + { + "name": "project_id", + "title": "立项项目ID", + "type": "str", + "length": 32, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "立项项目ID" + }, + { + "name": "archive_reason", + "title": "作废/存档原因", + "type": "str", + "length": 500, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "作废/存档原因" + }, + { + "name": "created_by", + "title": "创建人", + "type": "str", + "length": 32, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "创建人" + }, + { + "name": "created_at", + "title": "创建时间", + "type": "timestamp", + "nullable": "no", + "length": 0, + "uitype": "str", + "datatype": "timestamp", + "label": "创建时间" + }, + { + "name": "updated_at", + "title": "更新时间", + "type": "timestamp", + "length": 0, + "uitype": "str", + "datatype": "timestamp", + "label": "更新时间" + } +] + }, + + + + "search_form":{ + "css":"card", + "padding":"8px", + "show_label":false, + "submit_label":"搜索", + "submit_css":"primary", + "fields":[ + { + "name": "title_input", + "uitype": "str", + "placeholder": "项目名称", + "cwidth": 15 + }, + { + "name": "status_input", + "uitype": "code", + "placeholder": "状态", + "cwidth": 15, + "valueField": "status_input", + "textField": "status_input_text", + "params": { + "dbname": "{{get_module_dbname('pipeline_bidding')}}", + "table": "appcodes_kv", + "tblvalue": "k", + "tbltext": "v", + "valueField": "status_input", + "textField": "status_input_text", + "cond": "parentid='bid_tender_status'" + }, + "dataurl": "{{entire_url('/appbase/get_code.dspy')}}?prepend_all=1" + }, + { + "name": "purchaser_input", + "uitype": "str", + "placeholder": "招标人/采购人", + "cwidth": 15 + } +] + }, + + + + + + "page_rows":160, + "cache_limit":5 + } + + ,"binds":[] + +}] +} \ No newline at end of file diff --git a/wwwroot/bid_tenders/update_bid_tenders.dspy b/wwwroot/bid_tenders/update_bid_tenders.dspy new file mode 100644 index 0000000..66391d7 --- /dev/null +++ b/wwwroot/bid_tenders/update_bid_tenders.dspy @@ -0,0 +1,44 @@ + +ns = params_kw.copy() +for k,v in list(ns.items()): + if v == 'NaN' or v == 'null' or v == '' or v is None: + if k != 'id': + del ns[k] + else: + ns[k] = None if v in ('NaN', 'null') else v + + + + + +db = DBPools() +dbname = get_module_dbname('pipeline_bidding') +async with db.sqlorContext(dbname) as sor: + + tblfields = json.loads('[{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"}, {"name": "org_id", "title": "机构ID", "type": "str", "length": 32, "nullable": "no", "default": "0"}, {"name": "source", "title": "信息来源", "type": "str", "length": 16, "nullable": "no", "default": "mail"}, {"name": "account_id", "title": "邮箱配置ID", "type": "str", "length": 32}, {"name": "mail_uid", "title": "邮件UID", "type": "str", "length": 64}, {"name": "mail_from", "title": "发件人", "type": "str", "length": 255}, {"name": "mail_subject", "title": "邮件主题", "type": "str", "length": 500}, {"name": "received_at", "title": "收信时间", "type": "timestamp"}, {"name": "raw_content", "title": "邮件原文", "type": "text"}, {"name": "attach_json", "title": "附件清单(JSON)", "type": "text"}, {"name": "title", "title": "项目名称", "type": "str", "length": 255}, {"name": "purchaser", "title": "招标人/采购人", "type": "str", "length": 255}, {"name": "tender_no", "title": "招标编号", "type": "str", "length": 64}, {"name": "industry", "title": "行业", "type": "str", "length": 64}, {"name": "summary", "title": "内容摘要", "type": "text"}, {"name": "bid_start_at", "title": "投标开始时间", "type": "date"}, {"name": "bid_deadline", "title": "投标截止时间", "type": "date"}, {"name": "project_period", "title": "项目周期", "type": "str", "length": 64}, {"name": "budget_amount", "title": "招标金额", "type": "float", "length": 18, "dec": 2}, {"name": "deposit_amount", "title": "投标保证金", "type": "float", "length": 18, "dec": 2}, {"name": "qualification_req", "title": "资质要求摘要", "type": "text"}, {"name": "key_dates", "title": "关键时间节点(JSON)", "type": "text"}, {"name": "risk_note", "title": "风险提示", "type": "text"}, {"name": "status", "title": "状态", "type": "str", "length": 16, "nullable": "no", "default": "new"}, {"name": "approval_task_id", "title": "审批任务ID", "type": "str", "length": 32}, {"name": "project_id", "title": "立项项目ID", "type": "str", "length": 32}, {"name": "archive_reason", "title": "作废/存档原因", "type": "str", "length": 500}, {"name": "created_by", "title": "创建人", "type": "str", "length": 32}, {"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"}, {"name": "updated_at", "title": "更新时间", "type": "timestamp"}]') + tblfname = [ f['name'] for f in tblfields ] + other_keys = [ k for k in ns.keys() if k in tblfname and k != 'id' ] + if len(other_keys) > 0: + r = await sor.U('bid_tenders', ns) + debug('update success'); + return { + "widgettype":"Message", + "options":{ + "title":"Update Success", + "cwidth":16, + "cheight":9, + "timeout":3, + "message":"ok" + } + } + +return { + "widgettype":"Error", + "options":{ + "title":"Update Error", + "cwidth":16, + "cheight":9, + "timeout":3, + "message":"failed" + } +} \ No newline at end of file diff --git a/wwwroot/index.ui b/wwwroot/index.ui new file mode 100644 index 0000000..c1aa85c --- /dev/null +++ b/wwwroot/index.ui @@ -0,0 +1,119 @@ +{ + "widgettype": "VBox", + "options": {"width": "100%", "height": "100%", "padding": "0", "bgcolor": "#f5f7fa"}, + "subwidgets": [ + { + "widgettype": "HBox", + "options": {"width": "100%", "alignItems": "center", "padding": "16px 24px 8px 24px", "cheight": 6, "gap": "12px"}, + "subwidgets": [ + {"widgettype": "Title2", "options": {"text": "投标产线"}}, + {"widgettype": "Text", "options": {"text": "招标采集 → 审批 → 立项 → 解析 → 编写 → 评审 → 评分 → 交付", "cfontsize": 0.9, "color": "#94a3b8"}}, + {"widgettype": "Filler"}, + { + "widgettype": "Button", + "options": {"label": "📨 招标信息", "css": "small"}, + "binds": [{ + "wid": "self", "event": "click", "actiontype": "urlwidget", + "target": "Popup", + "popup_options": {"title": "招标信息", "width": "88%", "height": "82%", "auto_open": true}, + "url": "/pipeline-bidding/bid_tenders/index.ui" + }] + }, + { + "widgettype": "Button", + "options": {"label": "📚 投标知识库", "css": "small"}, + "binds": [{ + "wid": "self", "event": "click", "actiontype": "urlwidget", + "target": "Popup", + "popup_options": {"title": "公司投标知识库", "width": "88%", "height": "82%", "auto_open": true}, + "url": "/pipeline-bidding/bid_kb_docs/index.ui" + }] + }, + { + "widgettype": "Button", + "options": {"label": "✉️ 邮箱配置", "css": "small"}, + "binds": [{ + "wid": "self", "event": "click", "actiontype": "urlwidget", + "target": "Popup", + "popup_options": {"title": "招标邮箱配置", "width": "88%", "height": "82%", "auto_open": true}, + "url": "/pipeline-bidding/bid_mail_accounts/index.ui" + }] + } + ] + }, + { + "widgettype": "VScrollPanel", + "id": "bid_content", + "options": {"css": "filler", "width": "100%", "height": "100%"}, + "subwidgets": [ + { + "widgettype": "VBox", + "options": {"width": "100%", "padding": "16px 24px", "spacing": "16px"}, + "subwidgets": [ + { + "widgettype": "VBox", + "options": {"width": "100%", "padding": "16px", "bgcolor": "#ffffff", "border": "1px solid #e2e8f0", "borderRadius": "8px", "spacing": "8px"}, + "subwidgets": [ + {"widgettype": "Title3", "options": {"text": "使用流程"}}, + {"widgettype": "Text", "options": {"text": "1. 邮箱配置:录入阿里云企业邮(imap.qiye.aliyun.com:993,开启安全登录需填客户端专用密码)", "cfontsize": 0.95}}, + {"widgettype": "Text", "options": {"text": "2. 投标驾驶舱:从「开发产线」同款入口(侧栏 产线管理→驾驶舱)切换到投标项目,对话式推进;或直接说「采集招标信息」", "cfontsize": 0.95}}, + {"widgettype": "Text", "options": {"text": "3. 审批:招标信息摘要后提交审批,不通过自动存档作废;通过自动立项并发布上传招标文件任务", "cfontsize": 0.95}}, + {"widgettype": "Text", "options": {"text": "4. 解析与编写:上传招标文件后自动抽取评分项/得分规则/资质/投标文件要求 → 章节骨架 → 一章一任务自动编写与评审循环", "cfontsize": 0.95}}, + {"widgettype": "Text", "options": {"text": "5. 评分与交付:合成标书 → 整书按评分规则打分 → 不达标自动退回问题章节 → 通过后人工交付确认", "cfontsize": 0.95}} + ] + }, + { + "widgettype": "VBox", + "options": {"width": "100%", "padding": "16px", "bgcolor": "#ffffff", "border": "1px solid #e2e8f0", "borderRadius": "8px", "spacing": "8px"}, + "subwidgets": [ + {"widgettype": "Title3", "options": {"text": "投标项目数据"}}, + {"widgettype": "Text", "options": {"text": "项目内数据(评分项/章节/标书/资质/评审记录)请打开对应投标项目后,从驾驶舱上方功能菜单进入,或从侧栏菜单进入后在页面里按项目筛选。", "cfontsize": 0.95}}, + { + "widgettype": "HBox", + "options": {"gap": "8px"}, + "subwidgets": [ + { + "widgettype": "Button", + "options": {"label": "📑 评分项", "css": "small"}, + "binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "Popup", + "popup_options": {"title": "评分项与得分规则", "width": "88%", "height": "82%", "auto_open": true}, + "url": "/pipeline-bidding/bid_scoring_items/index.ui"}] + }, + { + "widgettype": "Button", + "options": {"label": "📝 章节", "css": "small"}, + "binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "Popup", + "popup_options": {"title": "标书章节", "width": "88%", "height": "82%", "auto_open": true}, + "url": "/pipeline-bidding/bid_chapters/index.ui"}] + }, + { + "widgettype": "Button", + "options": {"label": "📦 标书", "css": "small"}, + "binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "Popup", + "popup_options": {"title": "合成标书", "width": "88%", "height": "82%", "auto_open": true}, + "url": "/pipeline-bidding/bid_documents/index.ui"}] + }, + { + "widgettype": "Button", + "options": {"label": "🎯 资质", "css": "small"}, + "binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "Popup", + "popup_options": {"title": "招标所需资质", "width": "88%", "height": "82%", "auto_open": true}, + "url": "/pipeline-bidding/bid_qualifications/index.ui"}] + }, + { + "widgettype": "Button", + "options": {"label": "🔍 评审记录", "css": "small"}, + "binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "Popup", + "popup_options": {"title": "评审记录", "width": "88%", "height": "82%", "auto_open": true}, + "url": "/pipeline-bidding/bid_reviews/index.ui"}] + } + ] + } + ] + } + ] + } + ] + } + ] +}