feat: refresh material names in background every two days
This commit is contained in:
@@ -103,7 +103,8 @@ AR51's direct local request on 2026-10-09 authorizes a Git-synchronized dictiona
|
|||||||
|
|
||||||
- When a returned shortage has no name, resolve its `缺料物料` code from this local file. Cache contents do not determine shortages or delivery dates.
|
- When a returned shortage has no name, resolve its `缺料物料` code from this local file. Cache contents do not determine shortages or delivery dates.
|
||||||
- Feishu processing may read this file, but must not write or refresh it or call Kingdee for name lookup.
|
- Feishu processing may read this file, but must not write or refresh it or call Kingdee for name lookup.
|
||||||
- AR51 may maintain it outside Feishu using `scripts/refresh_material_names.py`, either importing a verified full `BD_MATERIAL` reference cache or invoking the existing approved read-only `kingdee_query.py` for `FNumber,FName` only, with no material filter.
|
- AR51 may maintain it outside Feishu using `scripts/refresh_material_names.py`, either importing a verified full `BD_MATERIAL` reference cache or invoking the bundled restricted `shared/runtime/kingdee_materials.py` / existing approved read-only `kingdee_query.py` for `FNumber,FName` only, with no material filter.
|
||||||
|
- AR51's direct local request authorizes the native background refresh: check hourly, read ERP when 48 hours have elapsed since the last successful refresh, add new names and update changed names. State and locks live in Git-ignored `logs/`; credentials stay in each computer's environment, Keychain or ignored `configs/material_cache_erp.local`. Feishu processing never triggers this updater.
|
||||||
- The dictionary is intended to be tracked in this AM516 Git repository. Keep credentials, full material-master exports, BOMs, specifications, quantities, prices and other raw ERP fields out of it.
|
- The dictionary is intended to be tracked in this AM516 Git repository. Keep credentials, full material-master exports, BOMs, specifications, quantities, prices and other raw ERP fields out of it.
|
||||||
- Empty, invalid, partial, or conflicting refresh results must preserve the previous file. Missing names must remain explicit unresolved items.
|
- Empty, invalid, partial, or conflicting refresh results must preserve the previous file. Missing names must remain explicit unresolved items.
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ model + quantity
|
|||||||
| `skills/joint-module-leadtime-report/SKILL.md` | Lead-time report workflow |
|
| `skills/joint-module-leadtime-report/SKILL.md` | Lead-time report workflow |
|
||||||
| `scripts/delivery_prediction_query_template.py` | Controlled API query script |
|
| `scripts/delivery_prediction_query_template.py` | Controlled API query script |
|
||||||
| `scripts/refresh_material_names.py` | Local-only maintenance: export the full material-code/name dictionary |
|
| `scripts/refresh_material_names.py` | Local-only maintenance: export the full material-code/name dictionary |
|
||||||
|
| `../../shared/runtime/install_material_name_refresh.py` | Install the native macOS background 48-hour refresh service |
|
||||||
|
| `../../shared/runtime/kingdee_materials.py` | Portable ERP client restricted to material codes and names |
|
||||||
| `data/material_names.json` | Git-synchronized material-code/name lookup; no ERP call during replies |
|
| `data/material_names.json` | Git-synchronized material-code/name lookup; no ERP call during replies |
|
||||||
| `docs/material_name_cache.md` | Cache source, refresh and cross-computer synchronization guide |
|
| `docs/material_name_cache.md` | Cache source, refresh and cross-computer synchronization guide |
|
||||||
| `rules/eRob关节模组相似型号推荐规则_v3.1.md` | Local packaged model recommendation rule source |
|
| `rules/eRob关节模组相似型号推荐规则_v3.1.md` | Local packaged model recommendation rule source |
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ Expected:
|
|||||||
- each usable model includes `缺料物料明细` listing only shortage names, preserving order and deduplicating names; use the API's `物料名称` / `缺料物料名称` first, then resolve `缺料物料` through `data/material_names.json`; the same behavior applies to continuation replies;
|
- each usable model includes `缺料物料明细` listing only shortage names, preserving order and deduplicating names; use the API's `物料名称` / `缺料物料名称` first, then resolve `缺料物料` through `data/material_names.json`; the same behavior applies to continuation replies;
|
||||||
- code-only shortage records with cached names display those names without calling Kingdee; unresolved codes and missing/invalid caches are reported explicitly without displaying codes or inferring names; an empty shortage list says `API未返回缺料记录`, and missing shortage data or invalid BOM keeps the unavailable-data reminder;
|
- code-only shortage records with cached names display those names without calling Kingdee; unresolved codes and missing/invalid caches are reported explicitly without displaying codes or inferring names; an empty shortage list says `API未返回缺料记录`, and missing shortage data or invalid BOM keeps the unavailable-data reminder;
|
||||||
- the cache is Git-trackable, contains only code/name mappings and source metadata, and works in another checkout without the source computer's paths or ERP credentials; refreshes with empty, invalid or conflicting data preserve the previous cache;
|
- the cache is Git-trackable, contains only code/name mappings and source metadata, and works in another checkout without the source computer's paths or ERP credentials; refreshes with empty, invalid or conflicting data preserve the previous cache;
|
||||||
|
- the native background refresher queries ERP only when at least 48 hours have elapsed since the last successful refresh; unchanged dictionaries do not rewrite the Git cache, added/renamed names are merged, and failed refreshes preserve the cache and last-success state;
|
||||||
|
- running the updater on another computer uses that computer's local read-only ERP credentials, never Git-synchronized credentials; the bot sees valid cache changes on its next lookup without a restart;
|
||||||
- reducer shortages are the sole exception: when `缺料物料` starts with `20.30.`, output only the reducer code, inventory, inspection, in-transit, existing-demand quantities, and in-transit coverage conclusion in the Skill-defined order;
|
- reducer shortages are the sole exception: when `缺料物料` starts with `20.30.`, output only the reducer code, inventory, inspection, in-transit, existing-demand quantities, and in-transit coverage conclusion in the Skill-defined order;
|
||||||
- shortage items outside the `20.30.` reducer prefix never expose their material codes or quantity details;
|
- shortage items outside the `20.30.` reducer prefix never expose their material codes or quantity details;
|
||||||
- result is marked as internal candidate;
|
- result is marked as internal candidate;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ AR51 于 2026-10-09 在本地 Codex 请求将金蝶 ERP 全量物料编码和名
|
|||||||
|
|
||||||
1. 仍由 AR836 接口提供缺料记录,不根据名称缓存判断缺料。
|
1. 仍由 AR836 接口提供缺料记录,不根据名称缓存判断缺料。
|
||||||
2. 优先使用缺料记录里的名称;名称缺失时,根据该条记录的 `缺料物料` 编码查本地缓存。
|
2. 优先使用缺料记录里的名称;名称缺失时,根据该条记录的 `缺料物料` 编码查本地缓存。
|
||||||
3. 首次需要时加载并校验文件,后续在内存中查询。回复过程不访问金蝶、不刷新缓存。
|
3. 首次需要时加载并校验文件,后续在内存中查询;检测到本地文件变化时重新加载。回复过程不访问金蝶、不刷新缓存,后台新增名称在下一次查询时生效。
|
||||||
4. 回复只列缺料物料名称并去重,原有减速器展开规则继续有效。
|
4. 回复只列缺料物料名称并去重,原有减速器展开规则继续有效。
|
||||||
5. 编码未命中,或文件缺失、损坏时,仍返回可用的其他名称,并提示未找到名称的记录数及维护原因,不编造名称。
|
5. 编码未命中,或文件缺失、损坏时,仍返回可用的其他名称,并提示未找到名称的记录数及维护原因,不编造名称。
|
||||||
|
|
||||||
@@ -46,4 +46,46 @@ python3 capabilities/am516-delivery-prediction/scripts/refresh_material_names.py
|
|||||||
|
|
||||||
维护电脑提交代码、规则和 `data/material_names.json`,推送到 AM516 Git 仓库。另一台电脑在该项目拉取对应提交后,重启原有飞书机器人进程,使新代码和缓存生效。
|
维护电脑提交代码、规则和 `data/material_names.json`,推送到 AM516 Git 仓库。另一台电脑在该项目拉取对应提交后,重启原有飞书机器人进程,使新代码和缓存生效。
|
||||||
|
|
||||||
运行机器人只需项目内的缓存文件,不需要维护电脑上的 TH51 仓库或金蝶 ERP 凭证。新增物料或改名后,由 AR51 在维护电脑再次刷新、提交、推送,运行电脑拉取并重启。
|
仅使用名称缓存回复时不需要 ERP 凭证;运行后台自动刷新时,需要在执行电脑配置自己的只读 ERP 凭证。新增物料或改名后,缓存文件变化会自动生效;修改机器人代码后仍需重启机器人。
|
||||||
|
|
||||||
|
## 每两天后台自动刷新
|
||||||
|
|
||||||
|
使用 macOS `launchd` 用户后台服务 `com.am516.material-name-refresh`,安装器根据执行电脑的实际路径生成配置。每小时启动一次轻量检查;距离上次成功查询满 48 小时后,才全量查询 ERP 的 `FNumber,FName`。首次安装立即查询一次;失败时保留缓存和上次成功时间,下次小时检查重试。电脑休眠或关机时不能保证精确到点,恢复运行后补查。
|
||||||
|
|
||||||
|
自动刷新会加入新增编码、更新已有名称,并保留本轮未返回的旧编码。没有名称变化时不重写 Git 中的缓存,仅更新 Git 忽略的 `logs/material_names_refresh_state.json`;有变化时先完成分页、验证名称无冲突,再原子替换文件。进程锁避免并发刷新,日志只记录状态、数量、时间和安全错误摘要。
|
||||||
|
|
||||||
|
### 另一台 Mac 的安装步骤
|
||||||
|
|
||||||
|
1. 拉取包含缓存和脚本的提交,进入 AM516 项目根目录。
|
||||||
|
2. 随项目提供的 `shared/runtime/kingdee_materials.py` 只允许查询物料编码和名称,不依赖 TH51 仓库。它读取本机环境变量、钥匙串中的 `TH51_KINGDEE_ACCT_ID` / `TH51_KINGDEE_USERNAME` / `TH51_KINGDEE_PASSWORD`,或本机配置文件 `configs/material_cache_erp.local`。
|
||||||
|
3. 如果本机未配置只读凭证,从 `configs/material_cache_erp.local.example` 复制为 `configs/material_cache_erp.local`,在本机编辑账套、用户名和密码,并将权限设为 `600`。该文件匹配 `configs/*.local`,不会进入 Git;不要把实际凭证写入命令、日志或提交。
|
||||||
|
4. 运行安装命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 shared/runtime/install_material_name_refresh.py install
|
||||||
|
```
|
||||||
|
|
||||||
|
安装后的路径随电脑生成,首次立即检查,以后每小时检查是否已满 48 小时。用户服务在该 macOS 用户登录期间运行;本机需允许该后台 Python 进程访问项目目录和使用已批准的网络。
|
||||||
|
|
||||||
|
维护电脑也可复用已经配置好的现有只读客户端:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 shared/runtime/install_material_name_refresh.py install \
|
||||||
|
--kingdee-client /path/to/TH51_Knowledge_System/91_Scripts/kingdee_openapi/kingdee_query.py
|
||||||
|
```
|
||||||
|
|
||||||
|
查看、预览和卸载:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 shared/runtime/install_material_name_refresh.py status
|
||||||
|
python3 shared/runtime/install_material_name_refresh.py preview
|
||||||
|
python3 shared/runtime/install_material_name_refresh.py uninstall
|
||||||
|
```
|
||||||
|
|
||||||
|
手动执行一次到期检查(不会重复查询未到期的数据):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 capabilities/am516-delivery-prediction/scripts/refresh_material_names.py --due
|
||||||
|
```
|
||||||
|
|
||||||
|
日志位于执行用户的 `~/Library/Logs/AM516_Feishu_Bot/material_name_refresh.log` 和 `material_name_refresh.error.log`。后台服务只更新本机缓存和状态;Git 提交、推送和其他电脑的拉取由既有同步流程执行,不在定时脚本内处理。
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import fcntl
|
||||||
import hashlib
|
import hashlib
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import json
|
import json
|
||||||
@@ -21,10 +22,13 @@ sys.path.insert(0, str(PROJECT_ROOT))
|
|||||||
from shared.runtime.material_name_cache import (
|
from shared.runtime.material_name_cache import (
|
||||||
MATERIAL_NAME_CACHE_FILE,
|
MATERIAL_NAME_CACHE_FILE,
|
||||||
MaterialNameCacheError,
|
MaterialNameCacheError,
|
||||||
|
load_material_names,
|
||||||
material_names_from_records,
|
material_names_from_records,
|
||||||
material_names_hash,
|
material_names_hash,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
BUNDLED_CLIENT = PROJECT_ROOT / "shared/runtime/kingdee_materials.py"
|
||||||
|
DEFAULT_STATE = PROJECT_ROOT / "logs/material_names_refresh_state.json"
|
||||||
|
|
||||||
def read_reference(path: Path) -> tuple[list[dict[str, Any]], str]:
|
def read_reference(path: Path) -> tuple[list[dict[str, Any]], str]:
|
||||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
@@ -83,20 +87,7 @@ def query_materials(client_path: Path) -> tuple[list[dict[str, Any]], str]:
|
|||||||
return rows, snapshot_at
|
return rows, snapshot_at
|
||||||
|
|
||||||
|
|
||||||
def write_cache(records: list[dict[str, Any]], snapshot_at: str, output: Path) -> dict[str, Any]:
|
def write_json_atomic(output: Path, payload: dict[str, Any]) -> None:
|
||||||
materials = material_names_from_records(records)
|
|
||||||
payload = {
|
|
||||||
"schema_version": 1,
|
|
||||||
"source": {
|
|
||||||
"system": "Kingdee K3 Cloud", "form_id": "BD_MATERIAL",
|
|
||||||
"field_keys": ["FNumber", "FName"], "filter": "",
|
|
||||||
"snapshot_at": snapshot_at, "record_count": len(records),
|
|
||||||
},
|
|
||||||
"generated_at": datetime.now(timezone(timedelta(hours=8))).isoformat(timespec="seconds"),
|
|
||||||
"material_count": len(materials),
|
|
||||||
"content_sha256": material_names_hash(materials),
|
|
||||||
"materials": materials,
|
|
||||||
}
|
|
||||||
output.parent.mkdir(parents=True, exist_ok=True)
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
temp_path = None
|
temp_path = None
|
||||||
try:
|
try:
|
||||||
@@ -110,33 +101,108 @@ def write_cache(records: list[dict[str, Any]], snapshot_at: str, output: Path) -
|
|||||||
finally:
|
finally:
|
||||||
if temp_path is not None:
|
if temp_path is not None:
|
||||||
temp_path.unlink(missing_ok=True)
|
temp_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def write_cache(
|
||||||
|
records: list[dict[str, Any]], snapshot_at: str, output: Path, *, merge_existing: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
materials = material_names_from_records(records)
|
||||||
|
retained_count = 0
|
||||||
|
if merge_existing and output.exists():
|
||||||
|
previous = load_material_names(output)
|
||||||
|
retained_count = len(previous.keys() - materials.keys())
|
||||||
|
materials = dict(sorted({**previous, **materials}.items()))
|
||||||
|
if materials == previous:
|
||||||
|
return json.loads(output.read_text(encoding="utf-8"))
|
||||||
|
payload = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"source": {
|
||||||
|
"system": "Kingdee K3 Cloud", "form_id": "BD_MATERIAL",
|
||||||
|
"field_keys": ["FNumber", "FName"], "filter": "",
|
||||||
|
"snapshot_at": snapshot_at, "record_count": len(records),
|
||||||
|
},
|
||||||
|
"generated_at": datetime.now(timezone(timedelta(hours=8))).isoformat(timespec="seconds"),
|
||||||
|
"material_count": len(materials),
|
||||||
|
"content_sha256": material_names_hash(materials),
|
||||||
|
"materials": materials,
|
||||||
|
}
|
||||||
|
if retained_count:
|
||||||
|
payload["source"]["retained_missing_count"] = retained_count
|
||||||
|
write_json_atomic(output, payload)
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_once(
|
||||||
|
*, output: Path, state_file: Path, due_only: bool, interval_hours: int,
|
||||||
|
client_path: Path, reference_json: Path | None = None, current_time: datetime | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
now = current_time or datetime.now(timezone(timedelta(hours=8)))
|
||||||
|
if due_only and state_file.exists():
|
||||||
|
try:
|
||||||
|
state = json.loads(state_file.read_text(encoding="utf-8"))
|
||||||
|
last = datetime.fromisoformat(state["last_success_at"])
|
||||||
|
if last.tzinfo is None:
|
||||||
|
raise ValueError
|
||||||
|
next_due = last + timedelta(hours=interval_hours)
|
||||||
|
except (OSError, ValueError, KeyError, TypeError):
|
||||||
|
next_due = now # Rebuild invalid local state only after a successful refresh.
|
||||||
|
if now < next_due:
|
||||||
|
return {"ok": True, "status": "NOT_DUE", "next_due_at": next_due.isoformat()}
|
||||||
|
previous = load_material_names(output) if output.exists() else {}
|
||||||
|
records, snapshot_at = read_reference(reference_json) if reference_json else query_materials(client_path)
|
||||||
|
incoming = material_names_from_records(records)
|
||||||
|
added_count = len(incoming.keys() - previous.keys())
|
||||||
|
renamed_count = sum(previous[code] != name for code, name in incoming.items() if code in previous)
|
||||||
|
payload = write_cache(records, snapshot_at, output, merge_existing=due_only)
|
||||||
|
completed_at = current_time or datetime.now(timezone(timedelta(hours=8)))
|
||||||
|
result = {
|
||||||
|
"ok": True, "status": "UPDATED" if added_count or renamed_count else "UNCHANGED",
|
||||||
|
"source_record_count": len(records), "material_count": payload["material_count"],
|
||||||
|
"added_count": added_count, "renamed_count": renamed_count,
|
||||||
|
"retained_missing_count": len(previous.keys() - incoming.keys()) if due_only else 0,
|
||||||
|
"snapshot_at": snapshot_at, "output": str(output),
|
||||||
|
}
|
||||||
|
if due_only:
|
||||||
|
write_json_atomic(state_file, {
|
||||||
|
"last_success_at": completed_at.isoformat(timespec="seconds"),
|
||||||
|
"last_result": result,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
source = parser.add_mutually_exclusive_group(required=True)
|
source = parser.add_mutually_exclusive_group()
|
||||||
source.add_argument("--reference-json", type=Path, help="Existing verified full material-master cache")
|
source.add_argument("--reference-json", type=Path, help="Existing verified full material-master cache")
|
||||||
source.add_argument("--kingdee-client", type=Path, help="Approved kingdee_query.py for a live read-only refresh")
|
source.add_argument("--kingdee-client", type=Path, help="Approved kingdee_query.py for a live read-only refresh")
|
||||||
parser.add_argument("--output", type=Path, default=MATERIAL_NAME_CACHE_FILE)
|
parser.add_argument("--output", type=Path, default=MATERIAL_NAME_CACHE_FILE)
|
||||||
|
parser.add_argument("--due", action="store_true", help="Query only after the successful refresh interval")
|
||||||
|
parser.add_argument("--interval-hours", type=int, default=48)
|
||||||
|
parser.add_argument("--state-file", type=Path, default=DEFAULT_STATE)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
if args.interval_hours <= 0 or (args.due and args.reference_json):
|
||||||
|
parser.error("--interval-hours 必须为正数;--due 只允许实时 ERP 刷新。")
|
||||||
|
args.state_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
before = hashlib.sha256(args.output.read_bytes()).hexdigest() if args.output.exists() else None
|
||||||
try:
|
try:
|
||||||
records, snapshot_at = (
|
with args.state_file.with_suffix(".lock").open("a") as lock:
|
||||||
read_reference(args.reference_json)
|
try:
|
||||||
if args.reference_json is not None
|
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
else query_materials(args.kingdee_client)
|
except BlockingIOError:
|
||||||
|
print(json.dumps({"ok": True, "status": "ALREADY_RUNNING"}))
|
||||||
|
return 0
|
||||||
|
result = refresh_once(
|
||||||
|
output=args.output, state_file=args.state_file, due_only=args.due,
|
||||||
|
interval_hours=args.interval_hours, client_path=args.kingdee_client or BUNDLED_CLIENT,
|
||||||
|
reference_json=args.reference_json,
|
||||||
)
|
)
|
||||||
payload = write_cache(records, snapshot_at, args.output)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
# Kingdee exceptions may contain server text: do not print arbitrary content.
|
# Kingdee exceptions may contain server text: do not print arbitrary content.
|
||||||
reason = str(exc) if isinstance(exc, MaterialNameCacheError) else f"读取物料数据失败({type(exc).__name__}),请检查只读连接和凭证配置。"
|
reason = str(exc) if isinstance(exc, MaterialNameCacheError) or type(exc).__name__ == "KingdeeMaterialError" else f"读取物料数据失败({type(exc).__name__}),请检查只读连接和凭证配置。"
|
||||||
print(json.dumps({"ok": False, "error": reason, "previous_cache_preserved": True}, ensure_ascii=False))
|
after = hashlib.sha256(args.output.read_bytes()).hexdigest() if args.output.exists() else None
|
||||||
|
print(json.dumps({"ok": False, "error": reason, "previous_cache_preserved": before == after}, ensure_ascii=False))
|
||||||
return 1
|
return 1
|
||||||
print(json.dumps({
|
print(json.dumps(result, ensure_ascii=False))
|
||||||
"ok": True, "source_record_count": payload["source"]["record_count"],
|
|
||||||
"material_count": payload["material_count"], "snapshot_at": snapshot_at,
|
|
||||||
"output": str(args.output),
|
|
||||||
}, ensure_ascii=False))
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ Do not use temporary curl, WebFetch, or any API method outside the controlled sc
|
|||||||
- For every usable model, add `缺料物料明细`. First read `物料名称`, or `缺料物料名称` when the former is absent or blank, from `data.采购信息.缺料明细[]`. If neither name is usable, look up that shortage record's `缺料物料` code in the Git-synchronized `data/material_names.json` cache.
|
- For every usable model, add `缺料物料明细`. First read `物料名称`, or `缺料物料名称` when the former is absent or blank, from `data.采购信息.缺料明细[]`. If neither name is usable, look up that shortage record's `缺料物料` code in the Git-synchronized `data/material_names.json` cache.
|
||||||
- Preserve the API order, omit duplicate names, and display names separated by `;`. Include both reducer and non-reducer shortage names.
|
- Preserve the API order, omit duplicate names, and display names separated by `;`. Include both reducer and non-reducer shortage names.
|
||||||
- Do not display material codes in the names list, infer names from code prefixes, or list materials from the complete BOM. The lookup only enriches API-returned shortages; it never identifies shortages itself. This added detail contains no codes, quantities, or specifications.
|
- Do not display material codes in the names list, infer names from code prefixes, or list materials from the complete BOM. The lookup only enriches API-returned shortages; it never identifies shortages itself. This added detail contains no codes, quantities, or specifications.
|
||||||
- Read the local dictionary once per bot process and use in-memory lookups thereafter. Never call Kingdee or refresh/write the cache while replying to Feishu messages. Pulling a new cache requires a bot restart to reload it.
|
- Reuse the local dictionary in memory and reload it when the file's timestamp, size or inode changes. Never call Kingdee or refresh/write the cache while replying to Feishu messages. Background cache updates take effect on the next lookup without restarting the bot.
|
||||||
- If a name is absent from both the API and local cache, retain the available names and state how many records could not be resolved. Report missing or invalid cache files explicitly. Ask for cache maintenance outside Feishu; do not invent names or treat unresolved records as no shortage.
|
- If a name is absent from both the API and local cache, retain the available names and state how many records could not be resolved. Report missing or invalid cache files explicitly. Ask for cache maintenance outside Feishu; do not invent names or treat unresolved records as no shortage.
|
||||||
- If the shortage list is empty, state `API未返回缺料记录`; missing shortage data or an invalid BOM retains the existing unavailable-data reminder. Do not infer that missing data means no shortage.
|
- If the shortage list is empty, state `API未返回缺料记录`; missing shortage data or an invalid BOM retains the existing unavailable-data reminder. Do not infer that missing data means no shortage.
|
||||||
|
|
||||||
|
|||||||
5
configs/material_cache_erp.local.example
Normal file
5
configs/material_cache_erp.local.example
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"acct_id": "",
|
||||||
|
"username": "",
|
||||||
|
"password": ""
|
||||||
|
}
|
||||||
67
shared/runtime/install_material_name_refresh.py
Normal file
67
shared/runtime/install_material_name_refresh.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""Install the native macOS background material-name refresher."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import plistlib
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
LABEL = "com.am516.material-name-refresh"
|
||||||
|
|
||||||
|
|
||||||
|
def launch_agent(project: Path, python: Path, log_dir: Path, client: Path | None = None) -> dict:
|
||||||
|
command = [str(python), str(project / "capabilities/am516-delivery-prediction/scripts/refresh_material_names.py"), "--due", "--interval-hours", "48"]
|
||||||
|
if client is not None:
|
||||||
|
command.extend(["--kingdee-client", str(client)])
|
||||||
|
return {
|
||||||
|
"Label": LABEL, "ProgramArguments": command, "WorkingDirectory": str(project),
|
||||||
|
"RunAtLoad": True, "StartInterval": 3600, "ProcessType": "Background",
|
||||||
|
"EnvironmentVariables": {"PYTHONDONTWRITEBYTECODE": "1", "PYTHONUNBUFFERED": "1"},
|
||||||
|
"StandardOutPath": str(log_dir / "material_name_refresh.log"),
|
||||||
|
"StandardErrorPath": str(log_dir / "material_name_refresh.error.log"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("action", choices=("install", "status", "uninstall", "preview"))
|
||||||
|
parser.add_argument("--python", type=Path, default=Path(sys.executable))
|
||||||
|
parser.add_argument("--kingdee-client", type=Path)
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.kingdee_client is not None:
|
||||||
|
args.kingdee_client = args.kingdee_client.resolve()
|
||||||
|
destination = Path.home() / "Library/LaunchAgents" / f"{LABEL}.plist"
|
||||||
|
logs = Path.home() / "Library/Logs/AM516_Feishu_Bot"
|
||||||
|
domain = f"gui/{os.getuid()}"
|
||||||
|
service = f"{domain}/{LABEL}"
|
||||||
|
if args.action == "preview":
|
||||||
|
print(plistlib.dumps(launch_agent(PROJECT_ROOT, args.python.resolve(), logs, args.kingdee_client)).decode())
|
||||||
|
return 0
|
||||||
|
if args.action == "status":
|
||||||
|
return subprocess.run(["launchctl", "print", service]).returncode
|
||||||
|
if args.action in ("install", "uninstall"):
|
||||||
|
exists = subprocess.run(["launchctl", "print", service], capture_output=True).returncode == 0
|
||||||
|
if exists:
|
||||||
|
subprocess.run(["launchctl", "bootout", service], check=True)
|
||||||
|
if args.action == "uninstall":
|
||||||
|
destination.unlink(missing_ok=True)
|
||||||
|
print("已移除物料名称后台刷新服务。")
|
||||||
|
return 0
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
logs.mkdir(parents=True, exist_ok=True)
|
||||||
|
(PROJECT_ROOT / "logs").mkdir(exist_ok=True)
|
||||||
|
with destination.open("wb") as handle:
|
||||||
|
plistlib.dump(launch_agent(PROJECT_ROOT, args.python.resolve(), logs, args.kingdee_client), handle)
|
||||||
|
subprocess.run(["launchctl", "bootstrap", domain, str(destination)], check=True)
|
||||||
|
print("已安装物料名称后台刷新:每小时检查,成功刷新后间隔48小时再次查询 ERP。")
|
||||||
|
print(f"服务:{service}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
110
shared/runtime/kingdee_materials.py
Normal file
110
shared/runtime/kingdee_materials.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
"""Standalone Kingdee reader restricted to material codes and names."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import getpass
|
||||||
|
import http.cookiejar
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
BASE_URL = "https://kingdeeapi.zeroerr.com"
|
||||||
|
LOCAL_CREDENTIALS_PATH = Path(__file__).resolve().parents[2] / "configs/material_cache_erp.local"
|
||||||
|
LOGIN_PATH = "/Kingdee.BOS.WebApi.ServicesStub.AuthService.ValidateUser.common.kdsvc"
|
||||||
|
QUERY_PATH = "/Kingdee.BOS.WebApi.ServicesStub.DynamicFormService.ExecuteBillQuery.common.kdsvc"
|
||||||
|
|
||||||
|
|
||||||
|
class KingdeeMaterialError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def credentials() -> dict[str, str]:
|
||||||
|
result = {key: os.environ.get(env, "") for key, env in (
|
||||||
|
("acct_id", "KINGDEE_ACCT_ID"), ("username", "KINGDEE_USERNAME"), ("password", "KINGDEE_PASSWORD"),
|
||||||
|
)}
|
||||||
|
if any(result.values()):
|
||||||
|
if not all(result.values()):
|
||||||
|
raise KingdeeMaterialError("金蝶运行环境凭证配置不完整。")
|
||||||
|
return result
|
||||||
|
if LOCAL_CREDENTIALS_PATH.exists():
|
||||||
|
try:
|
||||||
|
data = json.loads(LOCAL_CREDENTIALS_PATH.read_text(encoding="utf-8"))
|
||||||
|
result = {key: data.get(key) for key in result}
|
||||||
|
if not all(isinstance(value, str) and value for value in result.values()):
|
||||||
|
raise ValueError
|
||||||
|
return result
|
||||||
|
except (OSError, ValueError, AttributeError) as exc:
|
||||||
|
raise KingdeeMaterialError("本机金蝶凭证文件配置无效。") from exc
|
||||||
|
account = getpass.getuser()
|
||||||
|
for key, service in (
|
||||||
|
("acct_id", "TH51_KINGDEE_ACCT_ID"), ("username", "TH51_KINGDEE_USERNAME"), ("password", "TH51_KINGDEE_PASSWORD"),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
completed = subprocess.run(
|
||||||
|
["security", "find-generic-password", "-a", account, "-s", service, "-w"],
|
||||||
|
capture_output=True, text=True, timeout=15,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||||
|
raise KingdeeMaterialError("本机金蝶只读凭证不可用。") from exc
|
||||||
|
if completed.returncode:
|
||||||
|
raise KingdeeMaterialError("本机未配置金蝶只读凭证。")
|
||||||
|
result[key] = completed.stdout.rstrip("\r\n")
|
||||||
|
# macOS may return non-ASCII Keychain text as UTF-8 hexadecimal bytes.
|
||||||
|
for key, value in result.items():
|
||||||
|
try:
|
||||||
|
decoded = bytes.fromhex(value).decode("utf-8")
|
||||||
|
except (ValueError, UnicodeError):
|
||||||
|
continue
|
||||||
|
if any(ord(char) > 127 for char in decoded):
|
||||||
|
result[key] = decoded
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def build_opener() -> urllib.request.OpenerDirector:
|
||||||
|
return urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
|
||||||
|
|
||||||
|
|
||||||
|
def post(opener: Any, path: str, payload: Any) -> Any:
|
||||||
|
request = urllib.request.Request(
|
||||||
|
BASE_URL + path, data=json.dumps(payload, ensure_ascii=True).encode("utf-8"),
|
||||||
|
headers={"Content-Type": "application/json; charset=utf-8"}, method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with opener.open(request, timeout=30) as response:
|
||||||
|
return json.loads(response.read().decode("utf-8"))
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
raise KingdeeMaterialError(f"金蝶只读接口返回 HTTP {exc.code}。") from exc
|
||||||
|
except (urllib.error.URLError, OSError) as exc:
|
||||||
|
raise KingdeeMaterialError("金蝶只读接口网络或 TLS 连接失败。") from exc
|
||||||
|
except (ValueError, UnicodeError) as exc:
|
||||||
|
raise KingdeeMaterialError("金蝶只读接口响应格式无效。") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def login(opener: Any) -> None:
|
||||||
|
values = credentials()
|
||||||
|
result = post(opener, LOGIN_PATH, {
|
||||||
|
"acctID": values["acct_id"], "username": values["username"],
|
||||||
|
"password": values["password"], "lcid": 2052,
|
||||||
|
})
|
||||||
|
if not isinstance(result, dict) or not (result.get("IsSuccess") is True or result.get("LoginResultType") == 1):
|
||||||
|
raise KingdeeMaterialError("金蝶登录失败,请检查本机只读账号和凭证。")
|
||||||
|
|
||||||
|
|
||||||
|
def execute_list_query(opener: Any, args: Any) -> list[Any]:
|
||||||
|
if args.form_id != "BD_MATERIAL" or args.fields != "FNumber,FName" or args.filter:
|
||||||
|
raise KingdeeMaterialError("物料名称客户端只允许未筛选的 BD_MATERIAL 编码和名称查询。")
|
||||||
|
parameters = {
|
||||||
|
"FormId": "BD_MATERIAL", "FieldKeys": "FNumber,FName", "FilterString": "",
|
||||||
|
"OrderString": "FNumber ASC,FMaterialId ASC", "TopRowCount": 0,
|
||||||
|
"StartRow": args.start_row, "Limit": args.limit,
|
||||||
|
}
|
||||||
|
for payload in (parameters, {"data": parameters}):
|
||||||
|
result = post(opener, QUERY_PATH, payload)
|
||||||
|
if isinstance(result, list) and all(isinstance(row, list) and len(row) == 2 for row in result):
|
||||||
|
return result
|
||||||
|
raise KingdeeMaterialError("金蝶物料名称查询未返回有效数据,请核对只读查询权限。")
|
||||||
@@ -39,9 +39,19 @@ def material_names_from_records(records: list[dict[str, Any]]) -> dict[str, str]
|
|||||||
return dict(sorted(materials.items()))
|
return dict(sorted(materials.items()))
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=4)
|
|
||||||
def load_material_names(path: Path = MATERIAL_NAME_CACHE_FILE) -> dict[str, str]:
|
def load_material_names(path: Path = MATERIAL_NAME_CACHE_FILE) -> dict[str, str]:
|
||||||
"""Load once per process; a Git update takes effect after the bot restarts."""
|
"""Reuse memory until the local file changes; never access ERP."""
|
||||||
|
try:
|
||||||
|
stat = path.stat()
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise MaterialNameCacheError("本地物料名称缓存文件缺失") from exc
|
||||||
|
except OSError as exc:
|
||||||
|
raise MaterialNameCacheError("本地物料名称缓存文件不可读取") from exc
|
||||||
|
return _load_material_names(path, (stat.st_mtime_ns, stat.st_size, stat.st_ino))
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=4)
|
||||||
|
def _load_material_names(path: Path, version: tuple[int, int, int]) -> dict[str, str]:
|
||||||
try:
|
try:
|
||||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
except FileNotFoundError as exc:
|
except FileNotFoundError as exc:
|
||||||
@@ -62,3 +72,6 @@ def load_material_names(path: Path = MATERIAL_NAME_CACHE_FILE) -> dict[str, str]
|
|||||||
if payload.get("material_count") != len(materials) or payload.get("content_sha256") != material_names_hash(materials):
|
if payload.get("material_count") != len(materials) or payload.get("content_sha256") != material_names_hash(materials):
|
||||||
raise MaterialNameCacheError("本地物料名称缓存计数或完整性校验失败")
|
raise MaterialNameCacheError("本地物料名称缓存计数或完整性校验失败")
|
||||||
return materials
|
return materials
|
||||||
|
|
||||||
|
|
||||||
|
load_material_names.cache_clear = _load_material_names.cache_clear
|
||||||
|
|||||||
@@ -7,9 +7,12 @@ import tempfile
|
|||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
from datetime import datetime, timedelta
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from shared.runtime import material_name_cache as cache
|
from shared.runtime import material_name_cache as cache
|
||||||
|
from shared.runtime import install_material_name_refresh as installer
|
||||||
|
from shared.runtime import kingdee_materials as kingdee
|
||||||
|
|
||||||
|
|
||||||
script_path = Path(__file__).resolve().parents[1] / "capabilities/am516-delivery-prediction/scripts/refresh_material_names.py"
|
script_path = Path(__file__).resolve().parents[1] / "capabilities/am516-delivery-prediction/scripts/refresh_material_names.py"
|
||||||
@@ -52,14 +55,14 @@ class MaterialNameCacheTests(unittest.TestCase):
|
|||||||
refresh.write_cache(rows, "2026-10-09T11:00:00+08:00", self.path)
|
refresh.write_cache(rows, "2026-10-09T11:00:00+08:00", self.path)
|
||||||
self.assertEqual(self.path.read_bytes(), previous)
|
self.assertEqual(self.path.read_bytes(), previous)
|
||||||
|
|
||||||
def test_cache_integrity_is_checked_and_loaded_once_until_restart(self) -> None:
|
def test_cache_updates_are_seen_without_restart_and_integrity_is_checked(self) -> None:
|
||||||
refresh.write_cache([{"FNumber": "A", "FName": "编码器"}], "2026-10-09T10:00:00+08:00", self.path)
|
refresh.write_cache([{"FNumber": "A", "FName": "编码器"}], "2026-10-09T10:00:00+08:00", self.path)
|
||||||
self.assertEqual(cache.load_material_names(self.path)["A"], "编码器")
|
self.assertEqual(cache.load_material_names(self.path)["A"], "编码器")
|
||||||
|
refresh.write_cache([{"FNumber": "A", "FName": "新编码器"}, {"FNumber": "B", "FName": "轴承"}], "2026-10-11T10:00:00+08:00", self.path)
|
||||||
|
self.assertEqual(cache.load_material_names(self.path), {"A": "新编码器", "B": "轴承"})
|
||||||
payload = json.loads(self.path.read_text())
|
payload = json.loads(self.path.read_text())
|
||||||
payload["materials"]["A"] = "篡改名称"
|
payload["materials"]["A"] = "篡改名称"
|
||||||
self.path.write_text(json.dumps(payload))
|
self.path.write_text(json.dumps(payload))
|
||||||
self.assertEqual(cache.load_material_names(self.path)["A"], "编码器")
|
|
||||||
cache.load_material_names.cache_clear()
|
|
||||||
with self.assertRaisesRegex(cache.MaterialNameCacheError, "完整性校验失败"):
|
with self.assertRaisesRegex(cache.MaterialNameCacheError, "完整性校验失败"):
|
||||||
cache.load_material_names(self.path)
|
cache.load_material_names(self.path)
|
||||||
|
|
||||||
@@ -105,6 +108,64 @@ class MaterialNameCacheTests(unittest.TestCase):
|
|||||||
with self.assertRaisesRegex(cache.MaterialNameCacheError, "分页重复"):
|
with self.assertRaisesRegex(cache.MaterialNameCacheError, "分页重复"):
|
||||||
refresh.query_materials(Path("/approved/source/91_Scripts/kingdee_openapi/kingdee_query.py"))
|
refresh.query_materials(Path("/approved/source/91_Scripts/kingdee_openapi/kingdee_query.py"))
|
||||||
|
|
||||||
|
def test_due_refresh_queries_after_48_hours_and_merges_without_deleting_names(self) -> None:
|
||||||
|
start = datetime.fromisoformat("2026-10-09T10:00:00+08:00")
|
||||||
|
state = Path(self.temp.name) / "state.json"
|
||||||
|
rows = [{"FNumber": "A", "FName": "编码器"}, {"FNumber": "B", "FName": "轴承"}]
|
||||||
|
args = dict(output=self.path, state_file=state, due_only=True, interval_hours=48, client_path=Path("client.py"))
|
||||||
|
with mock.patch.object(refresh, "query_materials", return_value=(rows, start.isoformat())) as query:
|
||||||
|
first = refresh.refresh_once(**args, current_time=start)
|
||||||
|
self.assertEqual(first["added_count"], 2)
|
||||||
|
result = refresh.refresh_once(**args, current_time=start + timedelta(hours=47))
|
||||||
|
self.assertEqual(result["status"], "NOT_DUE")
|
||||||
|
query.assert_called_once()
|
||||||
|
query.return_value = ([{"FNumber": "A", "FName": "新编码器"}, {"FNumber": "C", "FName": "减速器"}], start.isoformat())
|
||||||
|
result = refresh.refresh_once(**args, current_time=start + timedelta(hours=48))
|
||||||
|
self.assertEqual(result["added_count"], 1)
|
||||||
|
self.assertEqual(result["renamed_count"], 1)
|
||||||
|
self.assertEqual(result["retained_missing_count"], 1)
|
||||||
|
self.assertEqual(cache.load_material_names(self.path), {"A": "新编码器", "B": "轴承", "C": "减速器"})
|
||||||
|
|
||||||
|
def test_unchanged_refresh_does_not_rewrite_git_cache_but_advances_due_state(self) -> None:
|
||||||
|
start = datetime.fromisoformat("2026-10-09T10:00:00+08:00")
|
||||||
|
state = Path(self.temp.name) / "state.json"
|
||||||
|
rows = [{"FNumber": "A", "FName": "编码器"}]
|
||||||
|
refresh.write_cache(rows, start.isoformat(), self.path)
|
||||||
|
before = self.path.read_bytes()
|
||||||
|
with mock.patch.object(refresh, "query_materials", return_value=(rows, start.isoformat())):
|
||||||
|
result = refresh.refresh_once(output=self.path, state_file=state, due_only=True, interval_hours=48, client_path=Path("client.py"), current_time=start)
|
||||||
|
self.assertEqual(result["status"], "UNCHANGED")
|
||||||
|
self.assertEqual(self.path.read_bytes(), before)
|
||||||
|
self.assertEqual(json.loads(state.read_text())["last_success_at"], start.isoformat())
|
||||||
|
|
||||||
|
def test_failed_due_refresh_preserves_cache_and_last_success(self) -> None:
|
||||||
|
start = datetime.fromisoformat("2026-10-09T10:00:00+08:00")
|
||||||
|
state = Path(self.temp.name) / "state.json"
|
||||||
|
refresh.write_cache([{"FNumber": "A", "FName": "编码器"}], start.isoformat(), self.path)
|
||||||
|
state.write_text(json.dumps({"last_success_at": start.isoformat()}))
|
||||||
|
previous = self.path.read_bytes(), state.read_bytes()
|
||||||
|
with mock.patch.object(refresh, "query_materials", side_effect=cache.MaterialNameCacheError("网络失败")):
|
||||||
|
with self.assertRaises(cache.MaterialNameCacheError):
|
||||||
|
refresh.refresh_once(output=self.path, state_file=state, due_only=True, interval_hours=48, client_path=Path("client.py"), current_time=start + timedelta(hours=48))
|
||||||
|
self.assertEqual((self.path.read_bytes(), state.read_bytes()), previous)
|
||||||
|
|
||||||
|
def test_launch_agent_uses_host_paths_due_gate_and_no_credentials(self) -> None:
|
||||||
|
job = installer.launch_agent(Path("/another-machine/AM516"), Path("/usr/bin/python3"), Path("/user/logs"))
|
||||||
|
self.assertEqual(job["StartInterval"], 3600)
|
||||||
|
self.assertTrue(job["RunAtLoad"])
|
||||||
|
self.assertIn("--due", job["ProgramArguments"])
|
||||||
|
self.assertIn("48", job["ProgramArguments"])
|
||||||
|
self.assertIn("/another-machine/AM516", job["WorkingDirectory"])
|
||||||
|
self.assertNotIn("KINGDEE_PASSWORD", job["EnvironmentVariables"])
|
||||||
|
self.assertNotIn("codex", " ".join(job["ProgramArguments"]).lower())
|
||||||
|
|
||||||
|
def test_bundled_kingdee_client_rejects_other_forms_or_fields_before_network(self) -> None:
|
||||||
|
for form, fields, filter_string in (("STK_INVENTORY", "FNumber,FName", ""), ("BD_MATERIAL", "FNumber,FName,FSpecification", ""), ("BD_MATERIAL", "FNumber,FName", "FNumber='A'")):
|
||||||
|
with self.subTest(form=form, fields=fields), mock.patch.object(kingdee, "post") as request:
|
||||||
|
with self.assertRaises(kingdee.KingdeeMaterialError):
|
||||||
|
kingdee.execute_list_query(object(), SimpleNamespace(form_id=form, fields=fields, filter=filter_string))
|
||||||
|
request.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user