初始提交:QuickTeam 工单接口导入工具
用「WCF + 通用存储过程网关」接口替代 Selenium(D://Learning//Python//工单自动化)导入工单: 单 Sprint 12 个项目 175s 跑完(旧方案 25~35 分钟),失败可断点续跑且不重复建单。 - 分层:transport(HTTP/协议)/ api(存储过程语义)/ domain(纯规则)/ workflow(阶段编排)/ store(SQLite 台账)/ report / CLI - 幂等:台账驱动断点续跑;dry-run 与跳过行不污染进度;--rollback-on-fail 清理半成品 - 命令:plan / apply / report / status,真实写入需 --yes - tools:只读探针、存储过程源码导出(sp_helptext)、导入诊断 - docs:调研与设计文档(含联调踩到的 5 个坑)+ 6 个关键存储过程源码 实测:Sprint 2026 Sprint 10 的 12 个项目全部完成建单→导入→确认→推送 TFS, 工单 225134–225145,35 个功能模块,重跑 plan 全部 skip-pushed。69 个单测通过。
This commit is contained in:
@@ -0,0 +1,365 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""QuickTeam 接口探针(只读)。
|
||||
|
||||
用途:在不动任何业务数据的前提下,验证「通过接口驱动 QuickTeam」的可行性,
|
||||
并输出一份「本 Sprint 各项目工单现状」清单(plan 阶段的雏形)。
|
||||
|
||||
只调用以下只读接口:
|
||||
usp_Authentication / usp_GetTeamsByUserId / usp_GetProjectsByTeamId
|
||||
usp_GetWeeklyWorkSheetsByProjectId / usp_GetWorkSheetById / usp_GetProjectUsersByProjectId
|
||||
可选(--upload-test)额外验证文件上传通道,会在服务器 WorkSheetImport 目录留一个临时文件,
|
||||
不触发任何解析或写库动作。
|
||||
|
||||
用法:
|
||||
python tools/api_probe.py --config config/config.json
|
||||
python tools/api_probe.py --config config/config.json --sprint "2026 Sprint 10" --dir "D:/Work/QuickTeam/2026 Sprint 10"
|
||||
python tools/api_probe.py --config config/config.json --upload-test
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 传输层:WCF + 通用存储过程网关
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class QtError(RuntimeError):
|
||||
"""接口/业务错误。"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class QtClient:
|
||||
base_url: str
|
||||
timeout: int = 60
|
||||
retries: int = 2
|
||||
|
||||
# WCF 服务路径(来自前端 app.js 的 a078 配置模块)
|
||||
common_url: str = "/Services/CommonService.svc/"
|
||||
file_url: str = "/Services/FileOperationService.svc/"
|
||||
|
||||
# -- 底层 ---------------------------------------------------------------
|
||||
|
||||
def _post(self, path: str, payload: Any = None) -> str:
|
||||
url = self.base_url.rstrip("/") + path
|
||||
data = b"" if payload is None else json.dumps(payload).encode("utf-8")
|
||||
last: Exception | None = None
|
||||
for attempt in range(self.retries + 1):
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
||||
return resp.read().decode("utf-8-sig", "replace")
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise QtError(self._extract_error(exc)) from None
|
||||
except (urllib.error.URLError, TimeoutError) as exc: # 网络层可重试
|
||||
last = exc
|
||||
if attempt < self.retries:
|
||||
time.sleep(1.5 * (attempt + 1))
|
||||
continue
|
||||
raise QtError(f"网络不可达:{exc}") from None
|
||||
raise QtError(str(last))
|
||||
|
||||
@staticmethod
|
||||
def _extract_error(exc: urllib.error.HTTPError) -> str:
|
||||
body = exc.read().decode("utf-8-sig", "replace")
|
||||
# WCF 返回 HTML 错误页,真正的错误在 "The exception message is '...'"
|
||||
match = re.search(r"exception message is '(.*?)'.?\s*See server logs", body, re.S)
|
||||
if match:
|
||||
return f"HTTP {exc.code}: {match.group(1).strip()}"
|
||||
plain = re.sub(r"<[^>]+>", " ", body)
|
||||
plain = re.sub(r"\s+", " ", plain).strip()
|
||||
return f"HTTP {exc.code}: {plain[-300:]}"
|
||||
|
||||
@staticmethod
|
||||
def _decode(raw: str) -> Any:
|
||||
"""WCF 返回值常是「JSON 字符串里再套一层 JSON」,最多解三层。"""
|
||||
value: Any = raw.strip()
|
||||
for _ in range(3):
|
||||
if not isinstance(value, str):
|
||||
break
|
||||
try:
|
||||
value = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
break
|
||||
return value
|
||||
|
||||
def _exec(self, conn: str, sp: str, params: list[tuple[str, Any, str]]) -> Any:
|
||||
"""调用存储过程。params: [(Name, Value, DBType), ...]"""
|
||||
inner = {
|
||||
"SPName": sp,
|
||||
"SQLParams": [
|
||||
{"Name": name, "Value": "" if value is None else str(value), "DBType": dbtype}
|
||||
for name, value, dbtype in params
|
||||
],
|
||||
}
|
||||
payload = {
|
||||
"connectionName": conn,
|
||||
"param": urllib.parse.quote(json.dumps(inner, separators=(",", ":"))),
|
||||
}
|
||||
return self._decode(self._post(self.common_url + "ExecTable", payload))
|
||||
|
||||
def _exec_result(self, conn: str, sp: str, params: list[tuple[str, Any, str]]) -> Any:
|
||||
"""同 _exec,但走 ExecResult(标量/返回值语义,如 Result、Column1)。"""
|
||||
inner = {
|
||||
"SPName": sp,
|
||||
"SQLParams": [
|
||||
{"Name": name, "Value": "" if value is None else str(value), "DBType": dbtype}
|
||||
for name, value, dbtype in params
|
||||
],
|
||||
}
|
||||
payload = {
|
||||
"connectionName": conn,
|
||||
"param": urllib.parse.quote(json.dumps(inner, separators=(",", ":"))),
|
||||
}
|
||||
return self._decode(self._post(self.common_url + "ExecResult", payload))
|
||||
|
||||
# -- 业务语义 -----------------------------------------------------------
|
||||
|
||||
def login(self, username: str, password: str) -> dict:
|
||||
digest = hashlib.md5(password.encode("utf-8")).hexdigest()
|
||||
rows = self._exec(
|
||||
"UserCenter",
|
||||
"dbo.usp_Authentication",
|
||||
[("UserName", username, "string"), ("Password", digest, "string")],
|
||||
)
|
||||
if not rows:
|
||||
raise QtError("登录失败:账号或密码不匹配")
|
||||
return rows[0]
|
||||
|
||||
def teams(self, user_id: str) -> list[dict]:
|
||||
return self._exec("QuickTeam", "QuickTeam.usp_GetTeamsByUserId", [("UserId", user_id, "string")]) or []
|
||||
|
||||
def projects(self, team_id: str) -> list[dict]:
|
||||
return self._exec(
|
||||
"QuickTeam",
|
||||
"QuickTeam.usp_GetProjectsByTeamId",
|
||||
[("TeamId", team_id, "string"), ("IsTemplate", "1", "string")],
|
||||
) or []
|
||||
|
||||
def worksheets(self, project_id: str, start: str, end: str) -> list[dict]:
|
||||
return self._exec(
|
||||
"QuickTeam",
|
||||
"QuickTeam.usp_GetWeeklyWorkSheetsByProjectId",
|
||||
[("ProjectId", project_id, "string"), ("Monday", start, "string"), ("Sunday", end, "string")],
|
||||
) or []
|
||||
|
||||
def worksheet(self, worksheet_id: int) -> dict | None:
|
||||
rows = self._exec("QuickTeam", "QuickTeam.usp_GetWorkSheetById", [("WorkSheetId", worksheet_id, "int")])
|
||||
return rows[0] if rows else None
|
||||
|
||||
def project_users(self, project_id: str) -> list[dict]:
|
||||
return self._exec(
|
||||
"QuickTeam", "QuickTeam.usp_GetProjectUsersByProjectId", [("ProjectId", project_id, "string")]
|
||||
) or []
|
||||
|
||||
# -- 文件通道 -----------------------------------------------------------
|
||||
|
||||
def upload(self, local_path: str, folder: str = "WorkSheetImport") -> str:
|
||||
"""上传文件,返回服务器端绝对路径(BatchImportFeature 的 filePath)。"""
|
||||
name = f"{int(time.time() * 1000)}{os.path.splitext(local_path)[1]}"
|
||||
url = (
|
||||
f"{self.base_url.rstrip('/')}{self.file_url}FileUpload"
|
||||
f"?fileName={urllib.parse.quote(name)}&fileFolder={urllib.parse.quote(folder)}"
|
||||
)
|
||||
boundary = "----qtprobe" + hashlib.md5(name.encode()).hexdigest()
|
||||
body = (
|
||||
f"--{boundary}\r\n"
|
||||
f'Content-Disposition: form-data; name="file"; filename="{name}"\r\n'
|
||||
f"Content-Type: application/octet-stream\r\n\r\n"
|
||||
).encode("utf-8")
|
||||
body += open(local_path, "rb").read()
|
||||
body += f"\r\n--{boundary}--\r\n".encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url, data=body, headers={"Content-Type": f"multipart/form-data; boundary={boundary}"}, method="POST"
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
||||
data = self._decode(resp.read().decode("utf-8-sig", "replace"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise QtError(self._extract_error(exc)) from None
|
||||
path = (data or {}).get("FileUploadResult")
|
||||
if not path:
|
||||
raise QtError(f"上传未返回 FileUploadResult:{data!r}")
|
||||
return path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 报表
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def ms_to_date(value: Any) -> str:
|
||||
"""QuickTeam 日期为 /Date(1785081600000)/ 形式。"""
|
||||
if not value:
|
||||
return ""
|
||||
match = re.search(r"\((\d+)", str(value))
|
||||
if not match:
|
||||
return str(value)
|
||||
return time.strftime("%Y-%m-%d", time.localtime(int(match.group(1)) / 1000))
|
||||
|
||||
|
||||
def list_xlsx(folder: str) -> list[str]:
|
||||
if not os.path.isdir(folder):
|
||||
raise SystemExit(f"目录不存在:{folder}")
|
||||
names = []
|
||||
for entry in sorted(os.listdir(folder)):
|
||||
path = os.path.join(folder, entry)
|
||||
if os.path.isfile(path) and entry.lower().endswith(".xlsx") and not entry.startswith("~$"):
|
||||
names.append(path)
|
||||
return names
|
||||
|
||||
|
||||
def normalize(name: str) -> str:
|
||||
return re.sub(r"\s+", "", name).replace("-", "-").replace("—", "-")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Row:
|
||||
project: str
|
||||
file: str
|
||||
project_id: str = ""
|
||||
existing: int = 0
|
||||
has_sprint: bool = False
|
||||
worksheet_ids: list[int] = field(default_factory=list)
|
||||
note: str = ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="QuickTeam 接口探针(只读)")
|
||||
parser.add_argument("--config", default="config/config.json", help="配置文件路径")
|
||||
parser.add_argument("--sprint", help="覆盖配置中的 sprint 名称(SystemName)")
|
||||
parser.add_argument("--dir", dest="folder", help="覆盖配置中的 xlsx 目录")
|
||||
parser.add_argument("--team", help="覆盖配置中的团队名称")
|
||||
parser.add_argument("--upload-test", action="store_true", help="额外验证上传通道(会留一个临时文件)")
|
||||
parser.add_argument("--json-out", help="把结果另存为 JSON")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not os.path.exists(args.config):
|
||||
raise SystemExit(f"缺少配置文件:{args.config}(可从 config/config.example.json 复制)")
|
||||
with open(args.config, encoding="utf-8-sig") as handle:
|
||||
cfg = json.load(handle)
|
||||
|
||||
sprint = args.sprint or cfg["sprint"]
|
||||
folder = args.folder or cfg["work_dir"]
|
||||
team_name = args.team or cfg.get("team_name", "当前项目")
|
||||
start, end = cfg.get("start_time", ""), cfg.get("end_time", "")
|
||||
if not start or not end:
|
||||
today = time.strftime("%Y-%m-%d")
|
||||
start, end = "2020-01-01", today
|
||||
|
||||
client = QtClient(cfg["base_url"], timeout=int(cfg.get("timeout", 60)))
|
||||
t0 = time.time()
|
||||
user = client.login(cfg["username"], cfg["password"])
|
||||
t_login = time.time() - t0
|
||||
print(f"[登录] {user['RealName']} <{user['UserName']}> UserId={user['GuidId']} {t_login:.2f}s")
|
||||
|
||||
t1 = time.time()
|
||||
teams = client.teams(user["GuidId"])
|
||||
team = next((x for x in teams if x.get("TeamName") == team_name), None)
|
||||
if not team:
|
||||
raise SystemExit(f"未找到团队「{team_name}」,可选:{[x.get('TeamName') for x in teams]}")
|
||||
t_teams = time.time() - t1
|
||||
|
||||
t2 = time.time()
|
||||
projects = client.projects(team["TeamId"])
|
||||
t_projects = time.time() - t2
|
||||
print(f"[团队] {team['TeamName']} ({team['TeamId']}) {t_teams:.2f}s")
|
||||
print(f"[项目] 共 {len(projects)} 个 {t_projects:.2f}s")
|
||||
|
||||
by_name = {normalize(p["ProjectName"]): p for p in projects}
|
||||
rows: list[Row] = []
|
||||
t3 = time.time()
|
||||
for path in list_xlsx(folder):
|
||||
stem = os.path.splitext(os.path.basename(path))[0]
|
||||
row = Row(project=stem, file=os.path.basename(path))
|
||||
hit = by_name.get(normalize(stem))
|
||||
if not hit:
|
||||
row.note = "项目名在 QuickTeam 中不存在"
|
||||
rows.append(row)
|
||||
continue
|
||||
row.project_id = hit["ProjectId"]
|
||||
sheets = client.worksheets(row.project_id, start, end)
|
||||
row.existing = len(sheets)
|
||||
for sheet in sheets:
|
||||
detail = client.worksheet(sheet["WorkSheetId"]) or {}
|
||||
row.worksheet_ids.append(sheet["WorkSheetId"])
|
||||
if detail.get("SystemName") == sprint:
|
||||
row.has_sprint = True
|
||||
rows.append(row)
|
||||
t_scan = time.time() - t3
|
||||
|
||||
width = max([len(r.project) for r in rows] + [8]) + 2
|
||||
print()
|
||||
print(f"{'项目'.ljust(width)} {'工单数':>5} {'本Sprint':>8} 状态")
|
||||
print("-" * (width + 34))
|
||||
for row in rows:
|
||||
if row.note:
|
||||
state = f"!! {row.note}"
|
||||
elif row.has_sprint:
|
||||
state = "已存在(应跳过或续跑)"
|
||||
else:
|
||||
state = "待创建"
|
||||
print(f"{row.project.ljust(width)} {row.existing:>5} {'是' if row.has_sprint else '否':>8} {state}")
|
||||
|
||||
todo = sum(1 for r in rows if not r.note and not r.has_sprint)
|
||||
missing = sum(1 for r in rows if r.note)
|
||||
print("-" * (width + 34))
|
||||
print(f"合计 {len(rows)} 个文件:待创建 {todo},已存在 {len(rows) - todo - missing},异常 {missing}")
|
||||
print(f"[耗时] 扫描 {t_scan:.2f}s 平均 {t_scan / max(len(rows), 1):.2f}s/项目")
|
||||
|
||||
if args.upload_test:
|
||||
sample = next((p for p in list_xlsx(folder)), None)
|
||||
if sample:
|
||||
t4 = time.time()
|
||||
remote = client.upload(sample)
|
||||
print(f"\n[上传] {os.path.basename(sample)} -> {remote} {time.time() - t4:.2f}s(已上传但不解析)")
|
||||
|
||||
if args.json_out:
|
||||
os.makedirs(os.path.dirname(os.path.abspath(args.json_out)), exist_ok=True)
|
||||
with open(args.json_out, "w", encoding="utf-8") as handle:
|
||||
json.dump(
|
||||
{
|
||||
"sprint": sprint,
|
||||
"team": team["TeamName"],
|
||||
"team_id": team["TeamId"],
|
||||
"user": {"RealName": user["RealName"], "GuidId": user["GuidId"]},
|
||||
"rows": [row.__dict__ for row in rows],
|
||||
"timing": {
|
||||
"login": round(t_login, 3),
|
||||
"teams": round(t_teams, 3),
|
||||
"projects": round(t_projects, 3),
|
||||
"scan": round(t_scan, 3),
|
||||
},
|
||||
},
|
||||
handle,
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
print(f"\n结果已写入 {args.json_out}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except QtError as error:
|
||||
print(f"[ERROR] {error}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
@@ -0,0 +1,109 @@
|
||||
"""诊断工具:定位 BatchImportFeature 空引用异常。
|
||||
|
||||
只操作一张临时草稿工单,结束时删除,不影响业务数据。
|
||||
|
||||
用法:
|
||||
python tools/diag_import.py # 建单→填单→读字段→试导入→删单
|
||||
python tools/diag_import.py --keep # 保留草稿(便于在页面上比对)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
from qtimport.api import CONN_QUICKTEAM, SP_IMPORT_EXCEL, SP_WORKSHEET, QuickTeamApi # noqa: E402
|
||||
from qtimport.config import Config # noqa: E402
|
||||
from qtimport.transport import Transport # noqa: E402
|
||||
|
||||
|
||||
def dump(title: str, rows) -> dict:
|
||||
print(f"\n=== {title} ===")
|
||||
if isinstance(rows, list) and rows:
|
||||
print(json.dumps(rows[0], ensure_ascii=False, indent=2))
|
||||
return rows[0]
|
||||
print(json.dumps(rows, ensure_ascii=False, indent=2))
|
||||
return {}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--config", default="config/config.json")
|
||||
parser.add_argument("--project", default="资管 - 华泰保险资管")
|
||||
parser.add_argument("--excel", default=r"D:\Work\QuickTeam\2026 Sprint 10\资管 - 华泰保险资管.xlsx")
|
||||
parser.add_argument("--keep", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
cfg = Config.load(args.config)
|
||||
t = Transport(cfg.base_url, timeout=cfg.timeout, retries=0)
|
||||
api = QuickTeamApi(t)
|
||||
user = api.login(cfg.username, cfg.password)
|
||||
team = api.resolve_team(api.teams(user.user_id), cfg.team_name)
|
||||
project = next(p for p in api.projects(team.team_id) if p.project_name == args.project)
|
||||
print(f"登录:{user.real_name};项目:{project.project_name}")
|
||||
|
||||
worksheet_id = api.create_worksheet()
|
||||
print(f"草稿工单:{worksheet_id}")
|
||||
try:
|
||||
dump("建单后", t.exec_table(CONN_QUICKTEAM, SP_WORKSHEET,
|
||||
[("WorkSheetId", worksheet_id, "int")], op="raw"))
|
||||
|
||||
evaluator, technical, customer = cfg.resolved_roles(user.real_name)
|
||||
print(f"角色:评估={evaluator} 技术={technical} 客户={customer}")
|
||||
result = api.fill_worksheet(
|
||||
worksheet_id, project,
|
||||
system_name=cfg.sprint, start_time=cfg.start_time, end_time=cfg.end_time,
|
||||
evaluator=evaluator, technical_leader=technical, customer_leader=customer,
|
||||
belong_team=cfg.belong_team, content=cfg.content_desc,
|
||||
operator=user.real_name, service_type=cfg.service_type,
|
||||
)
|
||||
print(f"填单 Result={result}")
|
||||
|
||||
after = dump("填单后", t.exec_table(CONN_QUICKTEAM, SP_WORKSHEET,
|
||||
[("WorkSheetId", worksheet_id, "int")], op="raw"))
|
||||
print(f"CurrentVersionId={after.get('CurrentVersionId')!r} VersionCode={after.get('VersionCode')!r}")
|
||||
|
||||
remote = api.upload_excel(args.excel)
|
||||
print(f"\n上传成功:{remote}")
|
||||
|
||||
# 逐个尝试参数组合,看哪种不报空引用
|
||||
variants = [
|
||||
("VersionId=0", [("WorkSheetId", worksheet_id, "int"), ("VersionId", 0, "int")]),
|
||||
("VersionId=-1", [("WorkSheetId", worksheet_id, "int"), ("VersionId", -1, "int")]),
|
||||
("VersionId 省略", [("WorkSheetId", worksheet_id, "int")]),
|
||||
("VersionId=空串", [("WorkSheetId", worksheet_id, "int"), ("VersionId", "", "string")]),
|
||||
]
|
||||
for label, params in variants:
|
||||
print(f"\n--- 尝试 {label} ---")
|
||||
try:
|
||||
out = t.batch_import(SP_IMPORT_EXCEL, params, remote)
|
||||
print("返回:", json.dumps(out, ensure_ascii=False))
|
||||
if isinstance(out, list) and out and isinstance(out[0], dict) and int(out[0].get("Column1", -1)) == 0:
|
||||
print(f"✔ {label} 成功")
|
||||
break
|
||||
except Exception as error: # noqa: BLE001
|
||||
print(f"✘ {type(error).__name__}: {error}")
|
||||
|
||||
dump("导入后", t.exec_table(CONN_QUICKTEAM, SP_WORKSHEET,
|
||||
[("WorkSheetId", worksheet_id, "int")], op="raw"))
|
||||
feats = api.features(worksheet_id)
|
||||
print(f"功能模块数量:{len(feats)}")
|
||||
for f in feats[:3]:
|
||||
print(" -", f.feature_id, f.code, f.name)
|
||||
finally:
|
||||
if args.keep:
|
||||
print(f"\n草稿保留:{worksheet_id}(请手工核对后删除)")
|
||||
else:
|
||||
try:
|
||||
api.delete_worksheet(worksheet_id)
|
||||
print(f"\n已删除草稿 {worksheet_id}")
|
||||
except Exception as error: # noqa: BLE001
|
||||
print(f"\n删除失败:{error}(请手工删除 {worksheet_id})")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,59 @@
|
||||
"""只读工具:导出 QuickTeam 存储过程源码。
|
||||
|
||||
网关 `ExecTable` 可以直接执行 `sys.sp_helptext`,因此无需数据库账号即可读到
|
||||
SP 定义 —— 排查参数语义、确认字段来源时比猜快得多。
|
||||
|
||||
用法:
|
||||
python tools/sp_source.py usp_VerifyWorkSheetById
|
||||
python tools/sp_source.py usp_InsertWorkSheet usp_UpdateWorkSheetById --out docs/sp
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
from qtimport.config import Config # noqa: E402
|
||||
from qtimport.transport import Transport # noqa: E402
|
||||
|
||||
|
||||
def fetch(transport: Transport, name: str) -> str:
|
||||
rows = transport.exec_table("QuickTeam", "sys.sp_helptext", [("objname", name, "string")], op="helptext")
|
||||
if not isinstance(rows, list) or not rows:
|
||||
return ""
|
||||
return "".join(str(row.get("Text", "")) for row in rows)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("names", nargs="+")
|
||||
parser.add_argument("--config", default="config/config.json")
|
||||
parser.add_argument("--out", default="")
|
||||
args = parser.parse_args()
|
||||
|
||||
cfg = Config.load(args.config)
|
||||
transport = Transport(cfg.base_url, timeout=60, retries=1)
|
||||
if args.out:
|
||||
os.makedirs(args.out, exist_ok=True)
|
||||
|
||||
for name in args.names:
|
||||
full = name if name.startswith("QuickTeam.") else f"QuickTeam.{name}"
|
||||
text = fetch(transport, full)
|
||||
if not text.strip():
|
||||
print(f"[空] {full}(不存在或无权读取)")
|
||||
continue
|
||||
print(f"===== {full} =====")
|
||||
print(text)
|
||||
if args.out:
|
||||
path = os.path.join(args.out, f"{name.split('.')[-1]}.sql")
|
||||
with open(path, "w", encoding="utf-8") as handle:
|
||||
handle.write(text)
|
||||
print(f"→ 已保存 {path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user