From b5cf9599f42ed80df5e07cd32d7149e1c6e5f1fc Mon Sep 17 00:00:00 2001 From: yumoqing Date: Fri, 28 Aug 2026 17:00:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=95=86=E6=9C=BA=E4=BA=A7=E7=BA=BF?= =?UTF-8?q?=E8=83=BD=E5=8A=9B=E5=8C=85=E2=80=94=E2=80=94=E6=8B=9B=E6=A0=87?= =?UTF-8?q?=E6=95=B0=E6=8D=AE(=E7=88=AC=E5=8F=96=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E5=86=85=E7=BD=91HTTP)=E2=86=92=E7=83=AD=E7=82=B9=E6=8E=A8?= =?UTF-8?q?=E8=8D=90=E2=86=92=E7=A0=94=E5=8F=91=E6=8A=A5=E5=91=8A=E2=86=92?= =?UTF-8?q?=E4=BA=BA=E5=B7=A5=E7=A1=AE=E8=AE=A4=E2=86=92=E7=A0=94=E5=8F=91?= =?UTF-8?q?=E5=AE=A1=E6=89=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.sh | 110 ++++++ init/data.json | 34 ++ json/build.sh | 6 + json/opp_approvals.json | 36 ++ json/opp_reports.json | 47 +++ models/opp_approvals.json | 70 ++++ models/opp_reports.json | 83 +++++ mysql.ddl.sql | 70 ++++ pipeline_opportunity/__init__.py | 1 + .../__pycache__/__init__.cpython-310.pyc | Bin 0 -> 179 bytes .../__pycache__/init.cpython-310.pyc | Bin 0 -> 1289 bytes .../__pycache__/opp_ability.cpython-310.pyc | Bin 0 -> 13739 bytes .../__pycache__/opp_common.cpython-310.pyc | Bin 0 -> 6498 bytes .../opp_data_capability.cpython-310.pyc | Bin 0 -> 1753 bytes .../opp_report_capability.cpython-310.pyc | Bin 0 -> 7582 bytes pipeline_opportunity/init.py | 35 ++ pipeline_opportunity/opp_ability.py | 338 ++++++++++++++++++ pipeline_opportunity/opp_common.py | 189 ++++++++++ pipeline_opportunity/opp_data_capability.py | 42 +++ pipeline_opportunity/opp_report_capability.py | 212 +++++++++++ pyproject.toml | 9 + scripts/__pycache__/load_path.cpython-310.pyc | Bin 0 -> 1716 bytes scripts/gen_ddl.sh | 6 + scripts/load_path.py | 59 +++ wwwroot/opp_approvals/add_opp_approvals.dspy | 37 ++ .../opp_approvals/delete_opp_approvals.dspy | 33 ++ wwwroot/opp_approvals/get_opp_approvals.dspy | 112 ++++++ wwwroot/opp_approvals/index.ui | 176 +++++++++ .../opp_approvals/update_opp_approvals.dspy | 36 ++ wwwroot/opp_reports/add_opp_reports.dspy | 37 ++ wwwroot/opp_reports/delete_opp_reports.dspy | 33 ++ wwwroot/opp_reports/get_opp_reports.dspy | 125 +++++++ wwwroot/opp_reports/index.ui | 208 +++++++++++ wwwroot/opp_reports/update_opp_reports.dspy | 36 ++ 34 files changed, 2180 insertions(+) create mode 100644 build.sh create mode 100644 init/data.json create mode 100644 json/build.sh create mode 100644 json/opp_approvals.json create mode 100644 json/opp_reports.json create mode 100644 models/opp_approvals.json create mode 100644 models/opp_reports.json create mode 100644 mysql.ddl.sql create mode 100644 pipeline_opportunity/__init__.py create mode 100644 pipeline_opportunity/__pycache__/__init__.cpython-310.pyc create mode 100644 pipeline_opportunity/__pycache__/init.cpython-310.pyc create mode 100644 pipeline_opportunity/__pycache__/opp_ability.cpython-310.pyc create mode 100644 pipeline_opportunity/__pycache__/opp_common.cpython-310.pyc create mode 100644 pipeline_opportunity/__pycache__/opp_data_capability.cpython-310.pyc create mode 100644 pipeline_opportunity/__pycache__/opp_report_capability.cpython-310.pyc create mode 100644 pipeline_opportunity/init.py create mode 100644 pipeline_opportunity/opp_ability.py create mode 100644 pipeline_opportunity/opp_common.py create mode 100644 pipeline_opportunity/opp_data_capability.py create mode 100644 pipeline_opportunity/opp_report_capability.py create mode 100644 pyproject.toml create mode 100644 scripts/__pycache__/load_path.cpython-310.pyc create mode 100644 scripts/gen_ddl.sh create mode 100644 scripts/load_path.py create mode 100644 wwwroot/opp_approvals/add_opp_approvals.dspy create mode 100644 wwwroot/opp_approvals/delete_opp_approvals.dspy create mode 100644 wwwroot/opp_approvals/get_opp_approvals.dspy create mode 100644 wwwroot/opp_approvals/index.ui create mode 100644 wwwroot/opp_approvals/update_opp_approvals.dspy create mode 100644 wwwroot/opp_reports/add_opp_reports.dspy create mode 100644 wwwroot/opp_reports/delete_opp_reports.dspy create mode 100644 wwwroot/opp_reports/get_opp_reports.dspy create mode 100644 wwwroot/opp_reports/index.ui create mode 100644 wwwroot/opp_reports/update_opp_reports.dspy diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..31bf63a --- /dev/null +++ b/build.sh @@ -0,0 +1,110 @@ +#!/bin/bash +# pipeline-opportunity 一键部署脚本(在宿主应用根目录执行或从本目录定位宿主)。 +# 职责:建表(运行期禁止 schema 变更,建表只在部署期)+ 种子数据(appcodes + pipelines 产线记录) +# + wwwroot 软链 + pip install 本包。 +# 参考模式:pipeline-bidding/build.sh。 +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)── +cd "$SCRIPT_DIR" +"$PYTHON" - "$APP_ROOT" <<'PYEOF' +import re +ddl = open('mysql.ddl.sql', encoding='utf-8').read() +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_opportunity_ddl.sql', 'w', encoding='utf-8').write(ddl) +print('DDL prepared:', ddl.count('CREATE TABLE IF NOT EXISTS'), 'tables') +PYEOF + +"$PYTHON" - "$APP_ROOT" <<'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(sys.argv[1], {'workdir': sys.argv[1]}) +kw = cfg.databases['pipeline'].kwargs +pwd = aes_decode_b64(cfg.password_key, kw.password) +with open('/tmp/pipeline_opportunity_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" "$APP_ROOT" <<'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(sys.argv[2], {'workdir': sys.argv[2]}) + db = DBPools(config.databases) + async with db.sqlorContext('pipeline') as sor: + 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', 'opportunity'), + 'version': '1.0.0', 'status': pl.get('status', 'published'), + 'org_id': pl.get('org_id', '0'), 'created_by': 'system', + }) + print('pipeline registered:', pl['id']) + 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-opportunity" +ln -sf "$SCRIPT_DIR/wwwroot" "$APP_ROOT/wwwroot/pipeline-opportunity" +echo "wwwroot linked: pipeline-opportunity" + +"$APP_ROOT/py3/bin/pip" install "$SCRIPT_DIR/" 2>&1 | tail -1 + +echo "pipeline-opportunity module deploy complete." +echo "后续(宿主负责):app 入口 import load_pipeline_opportunity;RBAC 用 scripts/load_path.py。" +echo "爬虫平台接入配置:appbase params 表设 tender_api_base / tender_api_token(默认 http://192.168.16.2:9085)。" diff --git a/init/data.json b/init/data.json new file mode 100644 index 0000000..1f61780 --- /dev/null +++ b/init/data.json @@ -0,0 +1,34 @@ +{ + "pipelines": [ + { + "id": "opportunity_general", + "name": "商机产线", + "pipeline_type": "opportunity", + "description": "招标市场情报(数据爬取平台)→热点软件推荐→每日AI/Agent招标→研发报告编写→人工确认→研发审批流程", + "status": "published", + "org_id": "0" + } + ], + "appcodes": [ + { + "parentid": "opp_report_status", + "parentname": "研发报告状态", + "items": [ + {"k": "draft", "v": "草稿"}, + {"k": "confirmed", "v": "人工确认通过"}, + {"k": "approval_initiated", "v": "已发起研发审批"}, + {"k": "approved", "v": "审批通过"}, + {"k": "rejected", "v": "审批驳回"} + ] + }, + { + "parentid": "opp_approval_status", + "parentname": "研发审批状态", + "items": [ + {"k": "initiated", "v": "已发起"}, + {"k": "approved", "v": "审批通过"}, + {"k": "rejected", "v": "审批驳回"} + ] + } + ] +} diff --git a/json/build.sh b/json/build.sh new file mode 100644 index 0000000..a1d95a9 --- /dev/null +++ b/json/build.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# 由 models/*.json + json/*.json 生成 wwwroot 下 CRUD 页面 +# 注意: py3/bin/xls2ui 的 shebang 可能是旧路径,直接用 python 调 +cd "$(dirname "$0")" +PY=../../pipeline-app/py3/bin/python +$PY ../../pipeline-app/py3/bin/xls2ui -m ../models -o ../wwwroot pipeline_opportunity opp_reports.json opp_approvals.json diff --git a/json/opp_approvals.json b/json/opp_approvals.json new file mode 100644 index 0000000..a1ce9c6 --- /dev/null +++ b/json/opp_approvals.json @@ -0,0 +1,36 @@ +{ + "tblname": "opp_approvals", + "title": "研发审批", + "params": { + "sortby": [ + "created_at desc" + ], + "confidential_fields": [], + "browserfields": { + "exclouded": [ + "note" + ] + }, + "editexclouded": [ + "id", + "created_at", + "resolved_at", + "resolved_by", + "report_id" + ], + "editable": { + "new_data_url": "{{entire_url('add_opp_approvals.dspy')}}", + "update_data_url": "{{entire_url('update_opp_approvals.dspy')}}", + "delete_data_url": "{{entire_url('delete_opp_approvals.dspy')}}" + }, + "data_filter": { + "AND": [ + { + "field": "status", + "op": "=", + "var": "status_input" + } + ] + } + } +} diff --git a/json/opp_reports.json b/json/opp_reports.json new file mode 100644 index 0000000..bb7f442 --- /dev/null +++ b/json/opp_reports.json @@ -0,0 +1,47 @@ +{ + "tblname": "opp_reports", + "title": "研发报告", + "params": { + "sortby": [ + "created_at desc" + ], + "confidential_fields": [], + "browserfields": { + "exclouded": [ + "content", + "confirm_task_id" + ] + }, + "editexclouded": [ + "id", + "created_at", + "updated_at", + "confirm_task_id", + "confirmed_by" + ], + "editable": { + "new_data_url": "{{entire_url('add_opp_reports.dspy')}}", + "update_data_url": "{{entire_url('update_opp_reports.dspy')}}", + "delete_data_url": "{{entire_url('delete_opp_reports.dspy')}}" + }, + "data_filter": { + "AND": [ + { + "field": "title", + "op": "LIKE", + "var": "title_input" + }, + { + "field": "software", + "op": "LIKE", + "var": "software_input" + }, + { + "field": "status", + "op": "=", + "var": "status_input" + } + ] + } + } +} diff --git a/models/opp_approvals.json b/models/opp_approvals.json new file mode 100644 index 0000000..4b9caeb --- /dev/null +++ b/models/opp_approvals.json @@ -0,0 +1,70 @@ +{ + "summary": [ + { + "name": "opp_approvals", + "title": "研发审批", + "primary": [ + "id" + ], + "catelog": "entity" + } + ], + "fields": [ + { + "name": "id", + "title": "主键ID", + "type": "str", + "length": 32, + "nullable": "no" + }, + { + "name": "report_id", + "title": "报告ID", + "type": "str", + "length": 32, + "nullable": "no" + }, + { + "name": "project_id", + "title": "项目ID", + "type": "str", + "length": 32 + }, + { + "name": "status", + "title": "审批状态", + "type": "str", + "length": 16, + "nullable": "no", + "default": "initiated" + }, + { + "name": "note", + "title": "审批说明", + "type": "text" + }, + { + "name": "created_by", + "title": "发起人", + "type": "str", + "length": 64 + }, + { + "name": "resolved_by", + "title": "审批人", + "type": "str", + "length": 64 + }, + { + "name": "created_at", + "title": "创建时间", + "type": "timestamp", + "nullable": "no" + }, + { + "name": "resolved_at", + "title": "结论时间", + "type": "timestamp" + } + ] +} diff --git a/models/opp_reports.json b/models/opp_reports.json new file mode 100644 index 0000000..c9a77a3 --- /dev/null +++ b/models/opp_reports.json @@ -0,0 +1,83 @@ +{ + "summary": [ + { + "name": "opp_reports", + "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 + }, + { + "name": "software", + "title": "软件/主题", + "type": "str", + "length": 128, + "nullable": "no" + }, + { + "name": "title", + "title": "报告标题", + "type": "str", + "length": 255, + "nullable": "no" + }, + { + "name": "content", + "title": "报告正文", + "type": "text" + }, + { + "name": "status", + "title": "报告状态", + "type": "str", + "length": 24, + "nullable": "no", + "default": "draft" + }, + { + "name": "confirm_task_id", + "title": "确认任务ID", + "type": "str", + "length": 32 + }, + { + "name": "confirmed_by", + "title": "确认人", + "type": "str", + "length": 64 + }, + { + "name": "created_by", + "title": "创建人", + "type": "str", + "length": 64 + }, + { + "name": "created_at", + "title": "创建时间", + "type": "timestamp", + "nullable": "no" + }, + { + "name": "updated_at", + "title": "更新时间", + "type": "timestamp" + } + ] +} diff --git a/mysql.ddl.sql b/mysql.ddl.sql new file mode 100644 index 0000000..d92c1f6 --- /dev/null +++ b/mysql.ddl.sql @@ -0,0 +1,70 @@ + +-- /home/ymq/work/pipeline/pipeline-opportunity/models/opp_reports.json + + + + + +-- 建库时请用以下语句,支持emoji字符 +-- CREATE DATABASE mydb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +drop table if exists opp_reports; +CREATE TABLE opp_reports +( + + `id` VARCHAR(32) NOT NULL comment '主键ID', + `project_id` VARCHAR(32) comment '项目ID', + `software` VARCHAR(128) NOT NULL comment '软件/主题', + `title` VARCHAR(255) NOT NULL comment '报告标题', + `content` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci comment '报告正文', + `status` VARCHAR(24) NOT NULL DEFAULT 'draft' comment '报告状态', + `confirm_task_id` VARCHAR(32) comment '确认任务ID', + `confirmed_by` VARCHAR(64) comment '确认人', + `created_by` VARCHAR(64) 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 '研发报告' +; + + +-- /home/ymq/work/pipeline/pipeline-opportunity/models/opp_approvals.json + + + + + +-- 建库时请用以下语句,支持emoji字符 +-- CREATE DATABASE mydb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +drop table if exists opp_approvals; +CREATE TABLE opp_approvals +( + + `id` VARCHAR(32) NOT NULL comment '主键ID', + `report_id` VARCHAR(32) NOT NULL comment '报告ID', + `project_id` VARCHAR(32) comment '项目ID', + `status` VARCHAR(16) NOT NULL DEFAULT 'initiated' comment '审批状态', + `note` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci comment '审批说明', + `created_by` VARCHAR(64) comment '发起人', + `resolved_by` VARCHAR(64) comment '审批人', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL comment '创建时间', + `resolved_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP comment '结论时间' + + +,primary key(id) + + +) +CHARACTER SET utf8mb4 +COLLATE utf8mb4_unicode_ci +engine=innodb +comment '研发审批' +; + diff --git a/pipeline_opportunity/__init__.py b/pipeline_opportunity/__init__.py new file mode 100644 index 0000000..b93bf1e --- /dev/null +++ b/pipeline_opportunity/__init__.py @@ -0,0 +1 @@ +from .init import load_pipeline_opportunity # noqa: F401 diff --git a/pipeline_opportunity/__pycache__/__init__.cpython-310.pyc b/pipeline_opportunity/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a116116dfffd2123917a386416a4043198bfefc7 GIT binary patch literal 179 zcmd1j<>g`k0%hNcSyn*$F^Gc(44TX@8G*u@ zjJG6n@)J|y3o;8*b29T%u00bGdfxfy3Lbd>3YPIH1cHVT!9-B;5SH;hfq05|$V0@aNW8>1gND3B z7(gL^@;w2egp!a|B`$xVB@&u$CgX}ZCfTJTz56@6pJt5)O|R4J{pS-?!5}RkH1}(? zdfT~OYS-spmqrtbc72`}N|FNQpM|96{wCX+29%^~Q4%=3wfGJ+dCyXn*k~)j6|;}Du@Fn5MviMK%QNA=t^~RGl$~J8JaG& z@_Y8pb@t#dS9WEGX1CbWin9W}a<7Z%_G+_w)1{>yOuL?;*^Smjjvd^fvuUTXBwZXF z{6@0(=Gbn=;WDxt8}!LG?@(Pn+~MaEE5gCfgAAv3x4FMa^Q(0B4luGi{{XDB8-O^q zWqWmo=L;o!bB(Ue&{`FGw3a97(_dkW?BEtFX6(YB95b1djNz;DNK~TPKkWRjO8^zn zi9OzGO_!X;zkrplBo&o}jjNod1wKNTQ|}&KUAXJ&E)4VJ(E}br+?-#-GE4|2(nW#? zP^y=J^;A3}j3bOp&pnZdMU2t6DE^^{Oz&7fM&t0EIte)+@tXdh@iZnr;!g{wQBmN? z?-22g<6kfd3>?0V;!!Yn)a}>9`*E&NF!>QszsXK>v@+#v!kKd047M=9LfAUi<*3xT z?~k_G?a&{jL&r(F98(Na)@5==>NVE1Ee;MxYjE_u00m~oZlOz)h>3r5LpQ^=TvTlh={722|Ie&dvPMC@oxBR0~ zJ+8!Kmd7ylkl>=R{5R5jq3*>8Z`x@eAKP$Z%sTznaXkse#`_RNkB9}&aeM-Pr(gvp zgqNZi#3I(;fpo|RQX@ngjzla!*T|*x#ANeQN3ZS&jPB)uqdS_q!U_1#3Y;C$NK%#0 S@vAbpwL}y+78FF`xaSq#5iFGe literal 0 HcmV?d00001 diff --git a/pipeline_opportunity/__pycache__/opp_ability.cpython-310.pyc b/pipeline_opportunity/__pycache__/opp_ability.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4ef77fcef880802c6d136e63ce63153ff319c99b GIT binary patch literal 13739 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 literal 0 HcmV?d00001 diff --git a/pipeline_opportunity/__pycache__/opp_report_capability.cpython-310.pyc b/pipeline_opportunity/__pycache__/opp_report_capability.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b050d1b64ff9d457fcc013df03b901ae069cfffb GIT binary patch literal 7582 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 wKq+j+Hy7|6Z str)══════════════════ + + +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_hot_software(sor, p, ctx): + from .opp_data_capability import hot_software + ok, res = await hot_software(sor, days=int(p.get("days") or 30), + top=int(p.get("top") or 10)) + if not ok: + return _fmt(False, res) + out = {"窗口": "%d天" % res.get("window_days"), + "窗口内招标总数": res.get("tenders_in_window"), + "排名": res.get("ranking")} + return json.dumps(out, ensure_ascii=False, default=str) + + +async def _h_daily_ai(sor, p, ctx): + from .opp_data_capability import daily_ai_tenders + ok, res = await daily_ai_tenders(sor, target_date=p.get("date") or "") + if not ok: + return _fmt(False, res) + return json.dumps({ + "date": res.get("date"), "count": res.get("count"), + "有预算条数": res.get("with_budget"), + "预算合计(万元)": res.get("total_budget_wan"), + "items": res.get("items"), + }, ensure_ascii=False, default=str) + + +async def _h_search(sor, p, ctx): + from .opp_data_capability import search_tenders + ok, res = await search_tenders(sor, keyword=p.get("keyword") or "", + days=int(p.get("days") or 7), + source=p.get("source") or "", + limit=int(p.get("limit") or 50)) + if not ok: + return _fmt(False, res) + return json.dumps({"count": res.get("count"), "items": res.get("items")}, + ensure_ascii=False, default=str) + + +async def _h_detail(sor, p, ctx): + from .opp_data_capability import tender_detail + ok, res = await tender_detail(sor, p.get("tender_id") or "") + if not ok: + return _fmt(False, res) + return json.dumps(res, ensure_ascii=False, default=str) + + +async def _h_crawler_stats(sor, p, ctx): + from .opp_data_capability import crawler_stats + ok, res = await crawler_stats(sor) + if not ok: + return _fmt(False, res) + return json.dumps(res, ensure_ascii=False, default=str) + + +async def _h_create_report(sor, p, ctx): + from .opp_report_capability import create_report + ok, rid = await create_report( + ctx.get("project_id") or "", p.get("software") or "", + title=p.get("title") or "", analysis=p.get("analysis") or "", + created_by="agent.main_agent") + return _fmt(ok, ("report_id=%s(草稿,用 opp_update_report 补写,完成后 opp_submit_report)" % rid) if ok else rid) + + +async def _h_update_report(sor, p, ctx): + from .opp_report_capability import update_report + ok, msg = await update_report(p.get("report_id") or "", p.get("content") or "") + return _fmt(ok, msg) + + +async def _h_list_reports(sor, p, ctx): + from .opp_report_capability import list_reports + rows = await list_reports(project_id=ctx.get("project_id") or "", + status=p.get("status") or "") + return _fmt_rows(rows, "尚无研发报告") + + +async def _h_get_report(sor, p, ctx): + from .opp_report_capability import get_report_full + rep = await get_report_full(p.get("report_id") or "") + if not rep: + return _fmt(False, "报告不存在") + return json.dumps(rep, ensure_ascii=False, default=str) + + +async def _h_submit_report(sor, p, ctx): + from .opp_report_capability import submit_for_confirmation + ok, msg = await submit_for_confirmation(p.get("report_id") or "", + note=p.get("note") or "") + return _fmt(ok, msg) + + +async def _h_list_approvals(sor, p, ctx): + from .opp_report_capability import list_approvals + rows = await list_approvals(project_id=ctx.get("project_id") or "", + status=p.get("status") or "") + return _fmt_rows(rows, "尚无审批单") + + +async def _h_diagnose(sor, p, ctx): + from .opp_report_capability import list_reports, list_approvals + from .opp_data_capability import crawler_stats + from .opp_common import find_human_task, HT_REPORT_CONFIRM, HT_DEV_APPROVAL + reps = await list_reports() + apps = await list_approvals() + ok, cs = await crawler_stats(sor) + dist = {} + for r in reps: + dist[r.get("status")] = dist.get(r.get("status"), 0) + 1 + ht_conf = await find_human_task(sor, ctx.get("project_id") or "", + HT_REPORT_CONFIRM, status="pending") + ht_appr = await find_human_task(sor, ctx.get("project_id") or "", + HT_DEV_APPROVAL, status="pending") + return json.dumps({ + "报告状态分布": dist, + "审批单": len(apps), + "待确认报告任务": ht_conf.get("title") if ht_conf else None, + "待审批任务": ht_appr.get("title") if ht_appr else None, + "爬取平台": ("连通,共 %s 条,24h 新增 %s" % ( + cs.get("total"), cs.get("collected_24h"))) if ok else "不可达: %s" % cs, + }, ensure_ascii=False, default=str) + + +OPP_HANDLERS = { + "opp_hot_software": _h_hot_software, + "opp_daily_ai_tenders": _h_daily_ai, + "opp_search_tenders": _h_search, + "opp_tender_detail": _h_detail, + "opp_crawler_stats": _h_crawler_stats, + "opp_create_report": _h_create_report, + "opp_update_report": _h_update_report, + "opp_list_reports": _h_list_reports, + "opp_get_report": _h_get_report, + "opp_submit_report": _h_submit_report, + "opp_list_approvals": _h_list_approvals, + "opp_diagnose": _h_diagnose, +} + + +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(OPP_TOOLS), dict(OPP_HANDLERS) + names = {t.name for t in OPP_TOOLS} + tools = list(OPP_TOOLS) + [t for t in SHARED_TOOLS if t.name not in names] + handlers = dict(SHARED_HANDLERS) + handlers.update(OPP_HANDLERS) # 同名以商机产线实现为准 + return tools, handlers + + +def register_opp_ability(): + """注册商机产线能力包(幂等)。""" + tools, handlers = _merge_shared() + ability = PipelineAbility( + pipeline_id=PIPELINE_ID, + name="商机产线", + tools=tools, + system_prompt=OPP_PROMPT, + handlers=handlers, + roles=OPP_ROLES, + menus=[ + {"label": "📊 研发报告", "icon": "", "url": "/pipeline-opportunity/opp_reports/index.ui", + "type": "popup", "width": "88%", "height": "82%"}, + {"label": "✅ 研发审批", "icon": "", "url": "/pipeline-opportunity/opp_approvals/index.ui", + "type": "popup", "width": "88%", "height": "82%"}, + ], + ) + register_ability(ability) + return ability + + +# ══════════════════ slash 命令(产线专属)══════════════════ + +async def _slash_hot(args, ctx): + ex = ctx.get("executor") + if not ex: + return "无执行器上下文" + return await ex._execute_ability_tool("opp_hot_software", {"days": args or 30}) + + +async def _slash_ai(args, ctx): + ex = ctx.get("executor") + if not ex: + return "无执行器上下文" + return await ex._execute_ability_tool("opp_daily_ai_tenders", {"date": args or ""}) + + +async def _slash_reports(args, ctx): + ex = ctx.get("executor") + if not ex: + return "无执行器上下文" + return await ex._execute_ability_tool("opp_list_reports", {}) + + +async def _slash_oppdiag(args, ctx): + ex = ctx.get("executor") + if not ex: + return "无执行器上下文" + return await ex._execute_ability_tool("opp_diagnose", {}) + + +def register_opp_slash_commands(): + for cmd in [ + SlashCommand("hot", "热点软件推荐(近30天招标排名)", _slash_hot, "pipeline", PIPELINE_ID), + SlashCommand("ai", "今日AI/Agent招标清单", _slash_ai, "pipeline", PIPELINE_ID), + SlashCommand("reports", "列出研发报告", _slash_reports, "pipeline", PIPELINE_ID), + SlashCommand("oppdiag", "商机产线诊断", _slash_oppdiag, "pipeline", PIPELINE_ID), + ]: + register_slash_command(cmd) + + +# import 即注册(与投标产线一致:宿主 import pipeline_opportunity 即生效) +register_opp_ability() +register_opp_slash_commands() diff --git a/pipeline_opportunity/opp_common.py b/pipeline_opportunity/opp_common.py new file mode 100644 index 0000000..d335a5c --- /dev/null +++ b/pipeline_opportunity/opp_common.py @@ -0,0 +1,189 @@ +"""商机产线公共层:DB 上下文、爬虫平台 HTTP 客户端、报告/审批状态、人工任务。 + +所有 opp_* capability 模块共用本模块,禁止各自重复实现。 + +爬虫平台接入:内网 HTTP(http_api.py),配置优先级 + 1. appbase params 表: tender_api_base / tender_api_token(系统级配置禁硬编码) + 2. 兜底默认: http://192.168.16.2:9085(内网地址,仅默认值) +""" + +import json +import logging +import urllib.parse + +from sqlor.dbpools import DBPools +from appPublic.uniqueID import getID + +DBNAME = "pipeline" +PIPELINE_ID = "opportunity_general" + +logger = logging.getLogger("pipeline.opportunity") + +# 爬虫平台默认接入点(内网网卡地址,公网不可达);可被 params 表覆盖 +DEFAULT_API_BASE = "http://192.168.16.2:9085" + +# ── 报告状态 ── +RP_DRAFT = "draft" # 草稿(agent 编写中/待审阅) +RP_CONFIRMED = "confirmed" # 人工确认通过(门禁) +RP_APPROVAL_INITIATED = "approval_initiated" # 已发起研发审批 +RP_APPROVED = "approved" # 审批通过 +RP_REJECTED = "rejected" # 审批驳回 + +# ── 审批状态 ── +AP_INITIATED = "initiated" +AP_APPROVED = "approved" +AP_REJECTED = "rejected" + +# ── 人工任务类型 ── +HT_REPORT_CONFIRM = "opp_report_confirm" # 报告人工确认门禁 +HT_DEV_APPROVAL = "opp_dev_approval" # 研发审批人工决策 + +# 报告状态流转(合法迁移) +REPORT_TRANSITIONS = { + RP_DRAFT: (RP_CONFIRMED,), + RP_CONFIRMED: (RP_APPROVAL_INITIATED,), + RP_APPROVAL_INITIATED: (RP_APPROVED, RP_REJECTED), + RP_REJECTED: (RP_DRAFT,), # 驳回可改回草稿重来 +} + + +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 rec_to_dict(rec): + """sqlor 行对象 → dict(过滤方法名)。""" + 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 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 表配置 + 默认兜底(系统级配置禁硬编码)。""" + 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 default + + +async def get_crawler_config(sor): + """爬虫平台接入配置 (base, token)。params 表优先,默认内网地址兜底。""" + base = await get_param(sor, "tender_api_base", DEFAULT_API_BASE) + token = await get_param(sor, "tender_api_token", "") + return (base or DEFAULT_API_BASE).rstrip("/"), token + + +async def crawler_get(sor, path, params=None, timeout=20): + """调爬虫平台只读接口。返回 (ok, payload|错误信息)。""" + base, token = await get_crawler_config(sor) + qs = "" + if params: + clean = {k: v for k, v in params.items() if v not in (None, "")} + if clean: + qs = "?" + urllib.parse.urlencode(clean) + url = base + path + qs + headers = {"X-API-Token": token} if token else {} + try: + import httpx + except ImportError: + httpx = None + try: + if httpx is not None: + async with httpx.AsyncClient(timeout=timeout) as cli: + r = await cli.get(url, headers=headers) + if r.status_code != 200: + return False, "爬虫平台返回 %d: %s" % (r.status_code, r.text[:200]) + return True, r.json() + # 无 httpx 时退回 urllib(同步阻塞,仅在缺依赖时用) + import asyncio + import urllib.request + + def _blocking(): + req = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read().decode("utf-8")) + + return True, await asyncio.get_event_loop().run_in_executor(None, _blocking) + except Exception as e: + logger.warning("crawler_get %s failed: %s", path, str(e)[:200]) + return False, "爬虫平台调用失败: %s" % str(e)[:200] + + +async def find_human_task(sor, project_id, task_type, status=None): + """查项目下指定类型的人工任务(供门禁判断与幂等创建)。""" + sql = ("SELECT id, title, status, task_type FROM pipeline_human_tasks " + "WHERE project_id=${pid}$ AND task_type=${tt}$") + args = {"pid": project_id, "tt": task_type} + if status: + sql += " AND status=${st}$" + args["st"] = status + sql += " ORDER BY created_at DESC LIMIT 1" + recs = await sor.sqlExe(sql, args) + await sor.sqlExe("COMMIT", {}) + return rec_to_dict(recs[0]) if recs else None + + +async def create_human_task(sor, project_id, task_type, title, description="", + assignee_role="owner"): + """创建项目级人工任务(确认/审批门禁)。幂等由调用方先 find 保证。""" + hid = new_id() + await sor.C("pipeline_human_tasks", { + "id": hid, + "project_id": project_id, + "task_type": task_type, + "title": title, + "description": description, + "status": "pending", + "assignee_role": assignee_role, + "created_by": "agent.opportunity", + }) + await sor.sqlExe("COMMIT", {}) + return hid + + +async def get_report(sor, report_id): + recs = await sor.sqlExe( + "SELECT * FROM opp_reports WHERE id=${i}$", {"i": report_id}) + await sor.sqlExe("COMMIT", {}) + return rec_to_dict(recs[0]) if recs else None + + +async def can_transition(cur_status, new_status): + return new_status in REPORT_TRANSITIONS.get(cur_status, ()) diff --git a/pipeline_opportunity/opp_data_capability.py b/pipeline_opportunity/opp_data_capability.py new file mode 100644 index 0000000..c2b6786 --- /dev/null +++ b/pipeline_opportunity/opp_data_capability.py @@ -0,0 +1,42 @@ +"""商机产线数据能力:从数据爬取平台拉取招标数据(只读)。 + +所有函数签名统一 (sor, **params),返回 (ok, payload|错误信息)。 +薄封装:查询条件透传给爬虫平台,本层不做二次加工(热点排序由爬虫平台算)。 +""" + +from .opp_common import crawler_get + + +async def search_tenders(sor, keyword="", days=7, source="", only_it=True, limit=50): + """关键词检索招标信息。""" + params = {"days": days, "limit": limit} + if keyword: + params["keyword"] = keyword + if source: + params["source"] = source + params["only_it"] = "1" if only_it else "0" + return await crawler_get(sor, "/api/tenders", params) + + +async def tender_detail(sor, tender_id): + """单条招标详情(含公告全文、来源 url)。""" + return await crawler_get(sor, "/api/tenders/%s" % tender_id) + + +async def daily_ai_tenders(sor, target_date=""): + """每日 AI/Agent 软件类招标展示数据。""" + params = {} + if target_date: + params["date"] = target_date + return await crawler_get(sor, "/api/ai_tenders", params) + + +async def hot_software(sor, days=30, top=10): + """热点软件主题排名(数量+预算+热点地区+样例)。""" + return await crawler_get(sor, "/api/hot_software", + {"days": days, "top": top}) + + +async def crawler_stats(sor): + """爬虫平台采集健康度。""" + return await crawler_get(sor, "/api/stats") diff --git a/pipeline_opportunity/opp_report_capability.py b/pipeline_opportunity/opp_report_capability.py new file mode 100644 index 0000000..948f175 --- /dev/null +++ b/pipeline_opportunity/opp_report_capability.py @@ -0,0 +1,212 @@ +"""商机产线报告/审批能力:研发报告编写 → 人工确认 → 研发审批流程。 + +状态机(REPORT_TRANSITIONS,opp_common): + draft → confirmed → approval_initiated → approved / rejected + └ rejected → draft(可重来) + +门禁纪律: +- confirm 只能 draft → confirmed,且是人工决策(由人工任务回流驱动) +- initiate_approval 只能 confirmed → approval_initiated(未经确认禁止发起审批) +- 审批结论由人工决策回流(approve/reject),agent 不得自批自审 +""" + +import logging + +from .opp_common import ( + get_db, new_id, rec_to_dict, rows_to_dicts, can_transition, + RP_DRAFT, RP_CONFIRMED, RP_APPROVAL_INITIATED, RP_APPROVED, RP_REJECTED, + AP_INITIATED, AP_APPROVED, AP_REJECTED, + HT_REPORT_CONFIRM, HT_DEV_APPROVAL, + find_human_task, create_human_task, get_report, +) + +logger = logging.getLogger("pipeline.opportunity.report") + + +async def create_report(project_id, software, title="", analysis="", + created_by="agent.opportunity"): + """创建研发报告草稿。返回 (True, report_id) 或 (False, 错误)。""" + if not software: + return False, "缺少 software(软件/主题名)" + if not title: + title = "%s 研发机会分析报告" % software + db, dbname = get_db() + rid = new_id() + async with db.sqlorContext(dbname) as sor: + await sor.C("opp_reports", { + "id": rid, + "project_id": project_id or "", + "software": software, + "title": title, + "content": analysis or "", + "status": RP_DRAFT, + "created_by": created_by, + }) + await sor.sqlExe("COMMIT", {}) + return True, rid + + +async def update_report(report_id, content): + """更新草稿报告内容(仅 draft 可改)。""" + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + rep = await get_report(sor, report_id) + if not rep: + return False, "报告不存在: %s" % report_id + if rep.get("status") != RP_DRAFT: + return False, "状态 %s 不可修改(仅草稿可编辑)" % rep.get("status") + await sor.U("opp_reports", {"id": report_id, "content": content}) + await sor.sqlExe("COMMIT", {}) + return True, "已更新" + + +async def list_reports(project_id="", status=""): + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + sql = ("SELECT id, project_id, software, title, status, created_by, " + "created_at, updated_at FROM opp_reports WHERE 1=1") + args = {} + if project_id: + sql += " AND project_id=${pid}$" + args["pid"] = project_id + if status: + sql += " AND status=${st}$" + args["st"] = status + sql += " ORDER BY created_at DESC LIMIT 50" + recs = await sor.sqlExe(sql, args) + await sor.sqlExe("COMMIT", {}) + return rows_to_dicts(recs) + + +async def get_report_full(report_id): + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + rep = await get_report(sor, report_id) + return rep + + +async def submit_for_confirmation(report_id, note=""): + """agent 完成报告后提交待人工确认:发人工确认任务(门禁)。 + + 报告保持 draft,创建/复用 pending 人工任务。人工确认后才进 confirmed。 + """ + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + rep = await get_report(sor, report_id) + if not rep: + return False, "报告不存在: %s" % report_id + if rep.get("status") != RP_DRAFT: + return False, "状态 %s 不可提交确认" % rep.get("status") + exist = await find_human_task(sor, rep.get("project_id", ""), + HT_REPORT_CONFIRM, status="pending") + if exist: + return True, "已有待确认任务: %s" % exist.get("id") + hid = await create_human_task( + sor, rep.get("project_id", ""), HT_REPORT_CONFIRM, + "确认研发报告:%s" % rep.get("title", ""), + note or "请审阅研发报告《%s》,确认后发起研发审批。" % rep.get("title", "")) + await sor.U("opp_reports", { + "id": report_id, "confirm_task_id": hid}) + await sor.sqlExe("COMMIT", {}) + return True, "已提交人工确认任务: %s" % hid + + +async def confirm_report(report_id, ok, operator=""): + """人工确认回流(门禁)。ok=True → confirmed;ok=False → 退回草稿待改。""" + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + rep = await get_report(sor, report_id) + if not rep: + return False, "报告不存在: %s" % report_id + if rep.get("status") != RP_DRAFT: + return False, "状态 %s 无法确认(仅草稿可确认)" % rep.get("status") + new_status = RP_CONFIRMED if ok else RP_DRAFT + await sor.U("opp_reports", { + "id": report_id, "status": new_status, + "confirmed_by": operator or "human"}) + # 完结对应的人工任务 + hid = rep.get("confirm_task_id") + if hid: + await sor.U("pipeline_human_tasks", { + "id": hid, "status": "done" if ok else "rejected"}) + await sor.sqlExe("COMMIT", {}) + return True, "已确认" if ok else "已退回草稿" + + +async def initiate_approval(report_id, note="", created_by="agent.opportunity"): + """发起研发审批:要求报告已 confirmed(人工确认门禁)。""" + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + rep = await get_report(sor, report_id) + if not rep: + return False, "报告不存在: %s" % report_id + if not await can_transition(rep.get("status"), RP_APPROVAL_INITIATED): + return False, "状态 %s 不可发起审批(须先人工确认)" % rep.get("status") + aid = new_id() + await sor.C("opp_approvals", { + "id": aid, + "report_id": report_id, + "project_id": rep.get("project_id", ""), + "status": AP_INITIATED, + "note": note, + "created_by": created_by, + }) + await sor.U("opp_reports", { + "id": report_id, "status": RP_APPROVAL_INITIATED}) + # 发人工审批任务 + await create_human_task( + sor, rep.get("project_id", ""), HT_DEV_APPROVAL, + "研发审批:%s" % rep.get("title", ""), + note or "请审批《%s》的研发立项。" % rep.get("title", "")) + await sor.sqlExe("COMMIT", {}) + return True, aid + + +async def resolve_approval(approval_id, result, operator=""): + """审批结论回流(人工决策)。result=approved/rejected。""" + if result not in (AP_APPROVED, AP_REJECTED): + return False, "result 必须是 approved 或 rejected" + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + recs = await sor.sqlExe( + "SELECT id, report_id, status FROM opp_approvals WHERE id=${i}$", + {"i": approval_id}) + await sor.sqlExe("COMMIT", {}) + if not recs: + return False, "审批单不存在: %s" % approval_id + rec = rec_to_dict(recs[0]) + if rec.get("status") != AP_INITIATED: + return False, "审批单状态 %s 无法结论" % rec.get("status") + report_status = RP_APPROVED if result == AP_APPROVED else RP_REJECTED + await sor.U("opp_approvals", { + "id": approval_id, "status": result, + "resolved_by": operator or "human"}) + await sor.U("opp_reports", { + "id": rec.get("report_id"), "status": report_status}) + # 完结人工审批任务 + ht = await find_human_task(sor, rec.get("project_id", ""), + HT_DEV_APPROVAL, status="pending") + if ht: + await sor.U("pipeline_human_tasks", { + "id": ht.get("id"), + "status": "done" if result == AP_APPROVED else "rejected"}) + await sor.sqlExe("COMMIT", {}) + return True, result + + +async def list_approvals(project_id="", status=""): + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + sql = ("SELECT id, report_id, project_id, status, note, created_by, " + "created_at, resolved_at FROM opp_approvals WHERE 1=1") + args = {} + if project_id: + sql += " AND project_id=${pid}$" + args["pid"] = project_id + if status: + sql += " AND status=${st}$" + args["st"] = status + sql += " ORDER BY created_at DESC LIMIT 50" + recs = await sor.sqlExe(sql, args) + await sor.sqlExe("COMMIT", {}) + return rows_to_dicts(recs) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..94770bd --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "pipeline_opportunity" +version = "0.1.0" +description = "商机产线:热点软件推荐→每日AI/Agent招标→研发报告→人工确认→研发审批。数据来自数据爬取平台(内网HTTP)。" +dependencies = [] + +[tool.setuptools.packages.find] +where = ["."] +include = ["pipeline_opportunity*"] diff --git a/scripts/__pycache__/load_path.cpython-310.pyc b/scripts/__pycache__/load_path.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3f6fb8e09744a62f7c2ab2684b743082ced9af63 GIT binary patch literal 1716 zcmZux-EUMy6rY(pd-v|`?zU7AG1c6lC0P@9A50`MKwMf>5@;K!53a$R+nr&#Wqa>s zW>&kKk`U_`Q7{^O@gre<5nj*)O$rwMD}0&wBrVXVzN-nIxx18AnaTX-%=ws)bIzQ& zP;e2n-;RDZf89ptwpk1|BYhc${2hRy8e*7{Ca&RhCN+}IR?T8ojdDDnxooGHP0``G zE@ss9F74u)%QDPvklIiK)$%OM>EnnwDdtji4K`@4z=tN$iDzm>USLC<^5RJXnt4M3 zZ2?^vpo;_a@I$(h*^6q0Gke(x+x8x+4YN`97|bJl8{2-Gh$bIdMi?=2JRPMStsF+^ z*um2N(3s5BkyD^yd+F`GmQB2agxMBwW3h9y@i;yn!N0~YbzJmZ(Lvh z^oKjk->?1h@!HC{dmsL|x%%Zs_ot0-zO76gdwsG}ojL6N@xkX_Gj6~>?Dn~L-Sw}p ztY7wdDi`jOJ&U7Io*(zN4=pU>mp2) ztTf|*`R46#jJpBxMpK>bkFw~Ha#;_78+^fyE2J)%MUW&SemiK&j#U|#ojgD#j2K@m zx57@&tbzuQRA+RHd?+3^yg~g`2^bA{|F|qOnY4R`W*f$~csu%rt%L=N|BuahlRsqF ziFsAOSukf|Ou&$D0Vp(&6`8jbHEFD}40p*ATEY#=2qUnzF3_`NiOf5Up0>nuD#tQL z4jeb}n0w4Ew#%RdA+5A zxhB^nmYSNVYC0c>k#-&=iFO5tbEA69xXvUZjFh&eVq6HFfy@sRxEwXPCyH>G#^Ndx zJAwLh`=c^OQy_xyuj4t)H&bW#W=wt?T>@A%!u*kw58AX;cAYH~gktrr7HVpNm zGPZyJesHRn{{4S_dk~~C&{hz&J2}sTDfNH0|AjjR@4;_2{La{99PzR@&MM<9EhDmD zN4VguWQ$RFsy@ zrwc(Cm4T9GyF8}Y!A0mj^sv}%v>e|zdcI#8)mh(XaozVt0VE=4V93Cjfi$?;U{0DO z5yQaJSr&>YSm0)plOYrzUyK;OJzG6^*mT%*pq=^Ouuz94GH$djB7%Y39WM literal 0 HcmV?d00001 diff --git a/scripts/gen_ddl.sh b/scripts/gen_ddl.sh new file mode 100644 index 0000000..408ffc7 --- /dev/null +++ b/scripts/gen_ddl.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# 生成 pipeline-opportunity 的 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_opportunity_ddl.py" diff --git a/scripts/load_path.py b/scripts/load_path.py new file mode 100644 index 0000000..5fede5b --- /dev/null +++ b/scripts/load_path.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""RBAC path registration for pipeline-opportunity module. + +与平台惯例一致:页面/CRUD/API → logined。 +在宿主应用根目录执行(set_role_perm.py 位于宿主根): + cd && py3/bin/python pkgs/pipeline-opportunity/scripts/load_path.py +""" +import os +import subprocess +import sys + +MOD = "pipeline-opportunity" +# set_role_perm.py 在宿主应用根目录(本脚本位于 /pkgs/pipeline-opportunity/scripts/) +APP_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) + +TABLES = [ + "opp_reports", "opp_approvals", +] + +PATHS_ANY = [] + +PATHS_LOGINED = [ + "/%s" % MOD, + "/%s/index.ui" % MOD, + "/%s/agent" % MOD, + "/%s/agent/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), + ] + + +def _run(role, path): + r = subprocess.run([sys.executable, os.path.join(APP_ROOT, "set_role_perm.py"), role, path], + capture_output=True, text=True, cwd=APP_ROOT) + 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/opp_approvals/add_opp_approvals.dspy b/wwwroot/opp_approvals/add_opp_approvals.dspy new file mode 100644 index 0000000..e384b12 --- /dev/null +++ b/wwwroot/opp_approvals/add_opp_approvals.dspy @@ -0,0 +1,37 @@ + +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_opportunity') +async with db.sqlorContext(dbname) as sor: + r = await sor.C('opp_approvals', ns.copy()) + return { + "widgettype":"Message", + "options":{ + "cwidth":16, + "cheight":9, + "title":"Add Success", + "timeout":3, + "message":"ok" + } + } + +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/opp_approvals/delete_opp_approvals.dspy b/wwwroot/opp_approvals/delete_opp_approvals.dspy new file mode 100644 index 0000000..4e451f8 --- /dev/null +++ b/wwwroot/opp_approvals/delete_opp_approvals.dspy @@ -0,0 +1,33 @@ + +ns = { + 'id':params_kw['id'], +} + + +db = DBPools() +dbname = get_module_dbname('pipeline_opportunity') +async with db.sqlorContext(dbname) as sor: + r = await sor.D('opp_approvals', 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/opp_approvals/get_opp_approvals.dspy b/wwwroot/opp_approvals/get_opp_approvals.dspy new file mode 100644 index 0000000..22ad591 --- /dev/null +++ b/wwwroot/opp_approvals/get_opp_approvals.dspy @@ -0,0 +1,112 @@ + +ns = params_kw.copy() + + +debug(f'get_opp_approvals.dspy:{ns=}') +if not ns.get('page'): + ns['page'] = 1 +if not ns.get('sort'): + + + ns['sort'] = ["created_at desc"] + + + +sql = '''select * from opp_approvals where 1=1 [[filterstr]]''' + +filterjson = params_kw.get('data_filter') +if filterjson and isinstance(filterjson, str): + try: + filterjson = json.loads(filterjson) + except (json.JSONDecodeError, TypeError): + filterjson = None +fields_str=r'''[ + { + "name": "id", + "title": "主键ID", + "type": "str", + "length": 32, + "nullable": "no" + }, + { + "name": "report_id", + "title": "报告ID", + "type": "str", + "length": 32, + "nullable": "no" + }, + { + "name": "project_id", + "title": "项目ID", + "type": "str", + "length": 32 + }, + { + "name": "status", + "title": "审批状态", + "type": "str", + "length": 16, + "nullable": "no", + "default": "initiated" + }, + { + "name": "note", + "title": "审批说明", + "type": "text" + }, + { + "name": "created_by", + "title": "发起人", + "type": "str", + "length": 64 + }, + { + "name": "resolved_by", + "title": "审批人", + "type": "str", + "length": 64 + }, + { + "name": "created_at", + "title": "创建时间", + "type": "timestamp", + "nullable": "no" + }, + { + "name": "resolved_at", + "title": "结论时间", + "type": "timestamp" + } +]''' +ori_fields = json.loads(fields_str) +if not filterjson: + fields = [ f['name'] for f in ori_fields ] + filterjson = default_filterjson(fields, ns) + +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_opportunity') +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/opp_approvals/index.ui b/wwwroot/opp_approvals/index.ui new file mode 100644 index 0000000..580e33b --- /dev/null +++ b/wwwroot/opp_approvals/index.ui @@ -0,0 +1,176 @@ + +{ + "widgettype":"VBox", + "options":{"cheight":40,"width":"100%"}, + "subwidgets":[{ + "id":"opp_approvals_tbl", + "widgettype":"Tabular", + "options":{ + "width":"100%", + "height":"100%", + + + "title":"研发审批", + + + + + "css":"card", + + + "editable":{ + + "new_data_url":"{{entire_url('add_opp_approvals.dspy')}}", + + + "delete_data_url":"{{entire_url('delete_opp_approvals.dspy')}}", + + + "update_data_url":"{{entire_url('update_opp_approvals.dspy')}}" + + }, + + + "data_url":"{{entire_url('./get_opp_approvals.dspy')}}", + + "data_method":"GET", + "data_params":{{json.dumps(params_kw, indent=4, ensure_ascii=False)}}, + "row_options":{ + + + + "browserfields": { + "exclouded": [ + "note" + ] +}, + + + "editexclouded":[ + "id", + "created_at", + "resolved_at", + "resolved_by", + "report_id" +], + + "fields":[ + { + "name": "id", + "title": "主键ID", + "type": "str", + "length": 32, + "nullable": "no", + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "主键ID" + }, + { + "name": "report_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, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "项目ID" + }, + { + "name": "status", + "title": "审批状态", + "type": "str", + "length": 16, + "nullable": "no", + "default": "initiated", + "cwidth": 16, + "uitype": "str", + "datatype": "str", + "label": "审批状态" + }, + { + "name": "note", + "title": "审批说明", + "type": "text", + "length": 0, + "uitype": "text", + "datatype": "text", + "label": "审批说明" + }, + { + "name": "created_by", + "title": "发起人", + "type": "str", + "length": 64, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "发起人" + }, + { + "name": "resolved_by", + "title": "审批人", + "type": "str", + "length": 64, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "审批人" + }, + { + "name": "created_at", + "title": "创建时间", + "type": "timestamp", + "nullable": "no", + "length": 0, + "uitype": "str", + "datatype": "timestamp", + "label": "创建时间" + }, + { + "name": "resolved_at", + "title": "结论时间", + "type": "timestamp", + "length": 0, + "uitype": "str", + "datatype": "timestamp", + "label": "结论时间" + } +] + }, + + + + "data_filter":{ + "AND": [ + { + "field": "status", + "op": "=", + "var": "status_input" + } + ] +}, + + + + + + "page_rows":160, + "cache_limit":5 + } + + ,"binds":[] + +}] +} \ No newline at end of file diff --git a/wwwroot/opp_approvals/update_opp_approvals.dspy b/wwwroot/opp_approvals/update_opp_approvals.dspy new file mode 100644 index 0000000..8270447 --- /dev/null +++ b/wwwroot/opp_approvals/update_opp_approvals.dspy @@ -0,0 +1,36 @@ + +ns = params_kw.copy() +for k,v in ns.items(): + if v == 'NaN' or v == 'null': + ns[k] = None + + + + +db = DBPools() +dbname = get_module_dbname('pipeline_opportunity') +async with db.sqlorContext(dbname) as sor: + + r = await sor.U('opp_approvals', 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/opp_reports/add_opp_reports.dspy b/wwwroot/opp_reports/add_opp_reports.dspy new file mode 100644 index 0000000..c797c3f --- /dev/null +++ b/wwwroot/opp_reports/add_opp_reports.dspy @@ -0,0 +1,37 @@ + +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_opportunity') +async with db.sqlorContext(dbname) as sor: + r = await sor.C('opp_reports', ns.copy()) + return { + "widgettype":"Message", + "options":{ + "cwidth":16, + "cheight":9, + "title":"Add Success", + "timeout":3, + "message":"ok" + } + } + +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/opp_reports/delete_opp_reports.dspy b/wwwroot/opp_reports/delete_opp_reports.dspy new file mode 100644 index 0000000..75eb0ee --- /dev/null +++ b/wwwroot/opp_reports/delete_opp_reports.dspy @@ -0,0 +1,33 @@ + +ns = { + 'id':params_kw['id'], +} + + +db = DBPools() +dbname = get_module_dbname('pipeline_opportunity') +async with db.sqlorContext(dbname) as sor: + r = await sor.D('opp_reports', 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/opp_reports/get_opp_reports.dspy b/wwwroot/opp_reports/get_opp_reports.dspy new file mode 100644 index 0000000..79dbb8c --- /dev/null +++ b/wwwroot/opp_reports/get_opp_reports.dspy @@ -0,0 +1,125 @@ + +ns = params_kw.copy() + + +debug(f'get_opp_reports.dspy:{ns=}') +if not ns.get('page'): + ns['page'] = 1 +if not ns.get('sort'): + + + ns['sort'] = ["created_at desc"] + + + +sql = '''select * from opp_reports where 1=1 [[filterstr]]''' + +filterjson = params_kw.get('data_filter') +if filterjson and isinstance(filterjson, str): + try: + filterjson = json.loads(filterjson) + except (json.JSONDecodeError, TypeError): + filterjson = None +fields_str=r'''[ + { + "name": "id", + "title": "主键ID", + "type": "str", + "length": 32, + "nullable": "no" + }, + { + "name": "project_id", + "title": "项目ID", + "type": "str", + "length": 32 + }, + { + "name": "software", + "title": "软件/主题", + "type": "str", + "length": 128, + "nullable": "no" + }, + { + "name": "title", + "title": "报告标题", + "type": "str", + "length": 255, + "nullable": "no" + }, + { + "name": "content", + "title": "报告正文", + "type": "text" + }, + { + "name": "status", + "title": "报告状态", + "type": "str", + "length": 24, + "nullable": "no", + "default": "draft" + }, + { + "name": "confirm_task_id", + "title": "确认任务ID", + "type": "str", + "length": 32 + }, + { + "name": "confirmed_by", + "title": "确认人", + "type": "str", + "length": 64 + }, + { + "name": "created_by", + "title": "创建人", + "type": "str", + "length": 64 + }, + { + "name": "created_at", + "title": "创建时间", + "type": "timestamp", + "nullable": "no" + }, + { + "name": "updated_at", + "title": "更新时间", + "type": "timestamp" + } +]''' +ori_fields = json.loads(fields_str) +if not filterjson: + fields = [ f['name'] for f in ori_fields ] + filterjson = default_filterjson(fields, ns) + +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_opportunity') +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/opp_reports/index.ui b/wwwroot/opp_reports/index.ui new file mode 100644 index 0000000..2678852 --- /dev/null +++ b/wwwroot/opp_reports/index.ui @@ -0,0 +1,208 @@ + +{ + "widgettype":"VBox", + "options":{"cheight":40,"width":"100%"}, + "subwidgets":[{ + "id":"opp_reports_tbl", + "widgettype":"Tabular", + "options":{ + "width":"100%", + "height":"100%", + + + "title":"研发报告", + + + + + "css":"card", + + + "editable":{ + + "new_data_url":"{{entire_url('add_opp_reports.dspy')}}", + + + "delete_data_url":"{{entire_url('delete_opp_reports.dspy')}}", + + + "update_data_url":"{{entire_url('update_opp_reports.dspy')}}" + + }, + + + "data_url":"{{entire_url('./get_opp_reports.dspy')}}", + + "data_method":"GET", + "data_params":{{json.dumps(params_kw, indent=4, ensure_ascii=False)}}, + "row_options":{ + + + + "browserfields": { + "exclouded": [ + "content", + "confirm_task_id" + ] +}, + + + "editexclouded":[ + "id", + "created_at", + "updated_at", + "confirm_task_id", + "confirmed_by" +], + + "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, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "项目ID" + }, + { + "name": "software", + "title": "软件/主题", + "type": "str", + "length": 128, + "nullable": "no", + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "软件/主题" + }, + { + "name": "title", + "title": "报告标题", + "type": "str", + "length": 255, + "nullable": "no", + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "报告标题" + }, + { + "name": "content", + "title": "报告正文", + "type": "text", + "length": 0, + "uitype": "text", + "datatype": "text", + "label": "报告正文" + }, + { + "name": "status", + "title": "报告状态", + "type": "str", + "length": 24, + "nullable": "no", + "default": "draft", + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "报告状态" + }, + { + "name": "confirm_task_id", + "title": "确认任务ID", + "type": "str", + "length": 32, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "确认任务ID" + }, + { + "name": "confirmed_by", + "title": "确认人", + "type": "str", + "length": 64, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "确认人" + }, + { + "name": "created_by", + "title": "创建人", + "type": "str", + "length": 64, + "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": "更新时间" + } +] + }, + + + + "data_filter":{ + "AND": [ + { + "field": "title", + "op": "LIKE", + "var": "title_input" + }, + { + "field": "software", + "op": "LIKE", + "var": "software_input" + }, + { + "field": "status", + "op": "=", + "var": "status_input" + } + ] +}, + + + + + + "page_rows":160, + "cache_limit":5 + } + + ,"binds":[] + +}] +} \ No newline at end of file diff --git a/wwwroot/opp_reports/update_opp_reports.dspy b/wwwroot/opp_reports/update_opp_reports.dspy new file mode 100644 index 0000000..ae87c86 --- /dev/null +++ b/wwwroot/opp_reports/update_opp_reports.dspy @@ -0,0 +1,36 @@ + +ns = params_kw.copy() +for k,v in ns.items(): + if v == 'NaN' or v == 'null': + ns[k] = None + + + + +db = DBPools() +dbname = get_module_dbname('pipeline_opportunity') +async with db.sqlorContext(dbname) as sor: + + r = await sor.U('opp_reports', 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