feat: resolve shortage names from Git-synced Kingdee material cache

This commit is contained in:
ar51
2026-10-09 10:18:34 +08:00
parent ad7c45d2d6
commit cf31549415
12 changed files with 7813 additions and 7 deletions

View File

@@ -20,6 +20,7 @@ capabilities/am516-delivery-prediction/
- Generate internal similar-model candidates.
- Query the AR836 delivery prediction API through the controlled local script.
- Produce an internal delivery candidate report for Feishu.
- Resolve code-only shortage names from the Git-synchronized material-name dictionary without a live Kingdee request during replies.
- Append a non-secret candidate report only to `capabilities/am516-delivery-prediction/records/delivery_prediction_records.md`.
## What This Bot Must Not Do
@@ -81,6 +82,8 @@ Minimum handoff material:
## Current Boundary
The material-name dictionary at `capabilities/am516-delivery-prediction/data/material_names.json` is approved by AR51's direct local request for Git synchronization. It includes only material codes, names and source metadata; full material-master exports, BOMs and credentials remain excluded. Refresh instructions are in `capabilities/am516-delivery-prediction/docs/material_name_cache.md`.
This is a pilot package. It is not a formal company AM, official system, customer commitment tool, or data security approval.
AR51 owns AM516 rules, functional design, implementation, validation, iteration, network access, account permissions, API-key use, and outbound-transfer approval. TH8 is the supporting IT department but owns only Git repository and synchronization-mechanism construction in this project. Outbound API access remains disabled until AR51 creates a trusted approval record explicitly covering `api.zeroerr-agent.com`, the AM516 use case, and API-key use.

View File

@@ -97,6 +97,16 @@ Include:
- human review item;
- fixed disclaimer.
### Material Name Cache
AR51's direct local request on 2026-10-09 authorizes a Git-synchronized dictionary of all Kingdee material codes and names for shortage-name lookup. The fixed file is `data/material_names.json`; it contains only `FNumber` / `FName` mappings and non-secret source/count/time/hash metadata.
- 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.
- 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.
- 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.
## 8. Fixed Disclaimer
```text

View File

@@ -34,6 +34,9 @@ model + quantity
| `skills/joint-module-model-recommendation/SKILL.md` | Model recommendation rules |
| `skills/joint-module-leadtime-report/SKILL.md` | Lead-time report workflow |
| `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 |
| `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 |
| `rules/eRob关节模组相似型号推荐规则_v3.1.md` | Local packaged model recommendation rule source |
| `records/delivery_prediction_records.md` | Local append-only candidate report record |
| `docs/deployment_checklist.md` | Deployment and environment checks |

File diff suppressed because it is too large Load Diff

View File

@@ -26,8 +26,9 @@ Expected:
- Feishu output uses rich-text `post`; the report title, overall-judgment heading, numbered candidate headings, and unavailable-candidate heading are bold;
- the judgment level and fixed disclaimer provide the global human-review boundary;
- inventory output is limited to summary-level available quantity and coverage; non-reducer shortage output never exposes material codes or raw ERP details;
- each usable model includes `缺料物料明细` listing only names from the API shortage records (`物料名称`, with `缺料物料名称` as the fallback), preserving order and deduplicating names; the same behavior applies to continuation replies;
- missing names are reported explicitly without substituting codes or inferring names; an empty shortage list says `API未返回缺料记录`, and missing shortage data or invalid BOM keeps the unavailable-data reminder;
- 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;
- 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;
- 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;
- result is marked as internal candidate;

View File

@@ -0,0 +1,49 @@
# 缺料物料名称本地缓存
AR51 于 2026-10-09 在本地 Codex 请求将金蝶 ERP 全量物料编码和名称缓存到可同步的 Git 文件,用于在 AR836 交期接口只返回缺料编码时补充名称。本文件记录该实现的范围和维护方式。
## 文件和数据范围
固定文件:`capabilities/am516-delivery-prediction/data/material_names.json`。
- 来源为 `BD_MATERIAL`,不按物料类型、审核状态、禁用状态或组织筛选。
- 仅保存编码 `FNumber` 和名称 `FName`,以及快照时间、来源行数、去重后编码数和内容哈希。
- 多组织的相同编码、相同名称合并成一条;同一编码出现不同名称时停止刷新,保留原缓存。
- 文件位于项目内,纳入 Git 同步。凭证、完整物料主数据、规格、BOM、数量和价格不进入这个文件。
- `source.snapshot_at` 是 ERP 查询或原参考缓存的时点;`generated_at` 是生成本文件的时间,不能把二者混为最新 ERP 数据时间。
## 回复逻辑
1. 仍由 AR836 接口提供缺料记录,不根据名称缓存判断缺料。
2. 优先使用缺料记录里的名称;名称缺失时,根据该条记录的 `缺料物料` 编码查本地缓存。
3. 首次需要时加载并校验文件,后续在内存中查询。回复过程不访问金蝶、不刷新缓存。
4. 回复只列缺料物料名称并去重,原有减速器展开规则继续有效。
5. 编码未命中,或文件缺失、损坏时,仍返回可用的其他名称,并提示未找到名称的记录数及维护原因,不编造名称。
## 在 AR51 的维护电脑刷新
以下命令均在 AM516 项目根目录运行。刷新脚本只能由本地维护执行,不能由飞书消息触发。
使用已配置只读金蝶凭证的现有客户端,全量分页查询两个字段:
```bash
PYTHONDONTWRITEBYTECODE=1 python3 capabilities/am516-delivery-prediction/scripts/refresh_material_names.py \
--kingdee-client /path/to/TH51_Knowledge_System/91_Scripts/kingdee_openapi/kingdee_query.py
```
其中路径替换为维护电脑上实际的已批准客户端路径。凭证仍由该客户端通过其已有的环境变量、钥匙串或本地凭证文件读取,不复制到 AM516 仓库。
也可以从已经核验的全量物料参考缓存导出,无需联网:
```bash
python3 capabilities/am516-delivery-prediction/scripts/refresh_material_names.py \
--reference-json /path/to/material_master.json
```
导出会校验来源对象、全量未筛选范围、计数和内容哈希,并保留原快照时间。空结果、异常分页、无效记录或名称冲突不会覆盖旧文件。成功后输出行数、编码数和快照时间,不输出全量物料内容或凭证。
## 同步到另一台电脑
维护电脑提交代码、规则和 `data/material_names.json`,推送到 AM516 Git 仓库。另一台电脑在该项目拉取对应提交后,重启原有飞书机器人进程,使新代码和缓存生效。
运行机器人只需项目内的缓存文件,不需要维护电脑上的 TH51 仓库或金蝶 ERP 凭证。新增物料或改名后,由 AR51 在维护电脑再次刷新、提交、推送,运行电脑拉取并重启。

View File

@@ -0,0 +1,144 @@
"""Refresh the Git-synchronized code/name cache outside the Feishu runtime."""
from __future__ import annotations
import argparse
import hashlib
import importlib.util
import json
import os
import sys
import tempfile
from datetime import datetime, timedelta, timezone
from pathlib import Path
from types import SimpleNamespace
from typing import Any
PROJECT_ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(PROJECT_ROOT))
from shared.runtime.material_name_cache import (
MATERIAL_NAME_CACHE_FILE,
MaterialNameCacheError,
material_names_from_records,
material_names_hash,
)
def read_reference(path: Path) -> tuple[list[dict[str, Any]], str]:
payload = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(payload, dict) or payload.get("form_id") != "BD_MATERIAL" or payload.get("filter") != "":
raise MaterialNameCacheError("来源必须为未筛选的 BD_MATERIAL 全量参考缓存。")
rows = payload.get("records")
if not isinstance(rows, list) or not all(isinstance(row, dict) for row in rows):
raise MaterialNameCacheError("来源缓存记录结构无效。")
digest = hashlib.sha256(
json.dumps(rows, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
).hexdigest()
if payload.get("record_count") != len(rows) or payload.get("content_sha256") != digest:
raise MaterialNameCacheError("来源缓存记录计数或完整性校验失败。")
snapshot_at = payload.get("generated_at")
if not isinstance(snapshot_at, str):
raise MaterialNameCacheError("来源缓存缺少快照时间。")
datetime.fromisoformat(snapshot_at)
return rows, snapshot_at
def query_materials(client_path: Path) -> tuple[list[dict[str, Any]], str]:
"""Use the existing approved read-only Kingdee client, querying only two fields."""
spec = importlib.util.spec_from_file_location("am516_kingdee_query", client_path)
if spec is None or spec.loader is None:
raise MaterialNameCacheError("无法加载金蝶只读查询器。")
client = importlib.util.module_from_spec(spec)
spec.loader.exec_module(client)
# Resolve the client's existing local credential fallback without copying it.
client.LOCAL_CREDENTIALS_PATH = client_path.resolve().parents[2] / client.LOCAL_CREDENTIALS_PATH
snapshot_at = datetime.now(timezone(timedelta(hours=8))).isoformat(timespec="seconds")
opener = client.build_opener()
client.login(opener)
rows: list[dict[str, Any]] = []
seen_pages: set[str] = set()
start_row = 0
while True:
args = SimpleNamespace(
command="bill-query", no_audit_log=True, form_id="BD_MATERIAL",
fields="FNumber,FName", filter="", order="FNumber ASC,FMaterialId ASC",
limit=2000, start_row=start_row,
)
page = client.execute_list_query(opener, args)
if not isinstance(page, list) or any(not isinstance(row, list) or len(row) != 2 for row in page):
raise MaterialNameCacheError("金蝶返回的物料分页结构无效,保留原缓存。")
if not page:
break
page_hash = hashlib.sha256(json.dumps(page, ensure_ascii=False).encode()).hexdigest()
if page_hash in seen_pages:
raise MaterialNameCacheError("金蝶分页重复,无法确认全量完成,保留原缓存。")
seen_pages.add(page_hash)
rows.extend({"FNumber": row[0], "FName": row[1]} for row in page)
start_row += len(page)
if len(page) < args.limit:
break
print(f"已读取物料主数据:{len(rows)}行", flush=True)
return rows, snapshot_at
def write_cache(records: list[dict[str, Any]], snapshot_at: str, output: Path) -> dict[str, Any]:
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)
temp_path = None
try:
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=output.parent, delete=False) as handle:
temp_path = Path(handle.name)
json.dump(payload, handle, ensure_ascii=False, indent=2)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(temp_path, output)
finally:
if temp_path is not None:
temp_path.unlink(missing_ok=True)
return payload
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
source = parser.add_mutually_exclusive_group(required=True)
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")
parser.add_argument("--output", type=Path, default=MATERIAL_NAME_CACHE_FILE)
args = parser.parse_args()
try:
records, snapshot_at = (
read_reference(args.reference_json)
if args.reference_json is not None
else query_materials(args.kingdee_client)
)
payload = write_cache(records, snapshot_at, args.output)
except Exception as exc:
# Kingdee exceptions may contain server text: do not print arbitrary content.
reason = str(exc) if isinstance(exc, MaterialNameCacheError) else f"读取物料数据失败({type(exc).__name__}),请检查只读连接和凭证配置。"
print(json.dumps({"ok": False, "error": reason, "previous_cache_preserved": True}, ensure_ascii=False))
return 1
print(json.dumps({
"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
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -128,10 +128,11 @@ Do not use temporary curl, WebFetch, or any API method outside the controlled sc
## Shortage Material Names Rule
- For every usable model, add `缺料物料明细` using only names from `data.采购信息.缺料明细[]`. Read `物料名称`, or `缺料物料名称` when the former is absent or blank.
- 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.
- Do not substitute material codes, infer names from code prefixes, or list materials from the complete BOM. This added detail contains no codes, quantities, or specifications.
- If some shortage records lack a nonblank string name, retain the available names and state how many records did not return a name. If all names are missing, state `N项缺料记录未返回物料名称,需人工复核`.
- 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.
- 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.
## Reducer Shortage Detail Rule

View File

@@ -22,6 +22,11 @@ truststore.inject_into_ssl()
import lark_oapi as lark
if __package__:
from .material_name_cache import MaterialNameCacheError, load_material_names
else:
from material_name_cache import MaterialNameCacheError, load_material_names
PROJECT_ROOT = Path(__file__).resolve().parents[2]
QUERY_SCRIPT = (
@@ -599,6 +604,8 @@ def shortage_material_names_line(shortages: list[Any]) -> str:
"""Show only names from shortage records, never codes or other raw fields."""
names: list[str] = []
missing_names = 0
cached_names: dict[str, str] | None = None
cache_error: str | None = None
for item in shortages:
name = None
if isinstance(item, dict):
@@ -607,6 +614,16 @@ def shortage_material_names_line(shortages: list[Any]) -> str:
if isinstance(value, str) and value.strip():
name = " ".join(value.split())
break
if name is None:
code = item.get("缺料物料")
if isinstance(code, str) and code.strip():
if cached_names is None and cache_error is None:
try:
cached_names = load_material_names()
except MaterialNameCacheError as exc:
cache_error = str(exc)
if cached_names is not None:
name = cached_names.get(code.strip())
if name is None:
missing_names += 1
elif name not in names:
@@ -614,7 +631,12 @@ def shortage_material_names_line(shortages: list[Any]) -> str:
text = ";".join(names)
if missing_names:
missing_text = f"{missing_names}项缺料记录未返回物料名称,需人工复核"
missing_text = f"{missing_names}项缺料记录未找到物料名称"
missing_text += (
f"({cache_error}),需补齐缓存后复核"
if cache_error is not None
else ",请核对物料编码并更新本地名称缓存后复核"
)
text = f"{text}(另有{missing_text})" if text else missing_text
return f"- 缺料物料明细:{text}"

View File

@@ -0,0 +1,64 @@
"""Portable, read-only material-code/name lookup for delivery replies."""
from __future__ import annotations
import hashlib
import json
from functools import lru_cache
from pathlib import Path
from typing import Any
MATERIAL_NAME_CACHE_FILE = (
Path(__file__).resolve().parents[2]
/ "capabilities/am516-delivery-prediction/data/material_names.json"
)
class MaterialNameCacheError(ValueError):
"""A cache error that contains no credentials or raw ERP records."""
def material_names_hash(materials: dict[str, str]) -> str:
content = json.dumps(materials, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(content.encode("utf-8")).hexdigest()
def material_names_from_records(records: list[dict[str, Any]]) -> dict[str, str]:
if not records:
raise MaterialNameCacheError("物料查询结果为空,保留原缓存。")
materials: dict[str, str] = {}
for row in records:
code, name = row.get("FNumber"), row.get("FName")
if not isinstance(code, str) or not code.strip() or not isinstance(name, str) or not name.strip():
raise MaterialNameCacheError("物料记录缺少有效编码或名称,保留原缓存。")
code, name = code.strip(), " ".join(name.split())
if code in materials and materials[code] != name:
raise MaterialNameCacheError("同一物料编码存在不同名称,需核对后再刷新缓存。")
materials[code] = name
return dict(sorted(materials.items()))
@lru_cache(maxsize=4)
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."""
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError as exc:
raise MaterialNameCacheError("本地物料名称缓存文件缺失") from exc
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
raise MaterialNameCacheError("本地物料名称缓存文件不可读取或格式错误") from exc
if not isinstance(payload, dict) or payload.get("schema_version") != 1:
raise MaterialNameCacheError("本地物料名称缓存版本或结构无效")
materials = payload.get("materials")
if not isinstance(materials, dict) or not materials:
raise MaterialNameCacheError("本地物料名称缓存为空或结构无效")
if any(
not isinstance(code, str) or not code.strip()
or not isinstance(name, str) or not name.strip()
for code, name in materials.items()
):
raise MaterialNameCacheError("本地物料名称缓存包含无效编码或名称")
if payload.get("material_count") != len(materials) or payload.get("content_sha256") != material_names_hash(materials):
raise MaterialNameCacheError("本地物料名称缓存计数或完整性校验失败")
return materials

View File

@@ -569,6 +569,58 @@ class UnavailableReasonTests(unittest.TestCase):
class ShortageMaterialNamesTests(unittest.TestCase):
def setUp(self) -> None:
patcher = mock.patch.object(bridge, "load_material_names", return_value={})
self.load_names = patcher.start()
self.addCleanup(patcher.stop)
def test_code_only_shortages_use_local_names_and_preserve_api_name_priority(self) -> None:
self.load_names.return_value = {"20.40.00001": "编码器", "20.50.00002": "轴承"}
lines = bridge.shortage_material_names_line([
{"缺料物料": "20.40.00001"},
{"缺料物料": " 20.50.00002 "},
{"缺料物料": "20.40.00001"},
{"缺料物料": "20.50.00002", "物料名称": "API更新后的轴承名称"},
])
self.assertEqual(lines, "- 缺料物料明细:编码器;轴承;API更新后的轴承名称")
self.load_names.assert_called_once_with()
self.assertNotIn("20.40", lines)
self.assertNotIn("20.50", lines)
def test_missing_or_invalid_cache_keeps_available_names_and_explains_failure(self) -> None:
for reason in ("本地物料名称缓存文件缺失", "本地物料名称缓存计数或完整性校验失败"):
with self.subTest(reason=reason):
self.load_names.side_effect = bridge.MaterialNameCacheError(reason)
line = bridge.shortage_material_names_line([
{"物料名称": "编码器"},
{"缺料物料": "20.50.00002"},
{"缺料物料": "20.50.00003"},
])
self.assertIn("编码器", line)
self.assertIn("2项缺料记录未找到物料名称", line)
self.assertIn(reason, line)
self.assertNotIn("20.50.", line)
def test_initial_and_continuation_code_only_shortages_resolve_offline(self) -> None:
request = bridge.RequestInput(model="eRob70H50I-FS-18CN[V5]", quantity=10)
self.load_names.return_value = {"20.40.00001": "编码器"}
def query(query_request: bridge.RequestInput) -> dict[str, object]:
payload = successful_payload()
payload["data"]["采购信息"]["缺料明细"] = [{"缺料物料": "20.40.00001"}]
return payload
for offset in (0, bridge.CANDIDATE_BATCH_SIZE):
with (
self.subTest(offset=offset),
mock.patch.object(bridge, "run_controlled_query", side_effect=query) as run_query,
mock.patch.object(bridge, "write_record"),
):
report = bridge.build_report(request, candidate_offset=offset)
self.assertEqual(report.count("- 缺料物料明细:编码器"), run_query.call_count)
self.assertNotIn("未找到物料名称", report)
self.assertNotIn("20.40.00001", report)
def test_all_shortage_names_are_shown_without_non_reducer_raw_fields(self) -> None:
payload = successful_payload()
payload["data"]["采购信息"]["缺料明细"] = [
@@ -601,7 +653,7 @@ class ShortageMaterialNamesTests(unittest.TestCase):
lines = bridge.lead_time_details(payload, requested_quantity=10)["evidence_lines"]
expected = "4项缺料记录未返回物料名称,需人工复核"
expected = "4项缺料记录未找到物料名称,请核对物料编码并更新本地名称缓存后复核"
if valid_name:
expected = f"轴承(另有{expected})"
self.assertEqual(lines, [f"- 缺料物料明细:{expected}"])

View File

@@ -0,0 +1,110 @@
from __future__ import annotations
import hashlib
import importlib.util
import json
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest import mock
from shared.runtime import material_name_cache as cache
script_path = Path(__file__).resolve().parents[1] / "capabilities/am516-delivery-prediction/scripts/refresh_material_names.py"
spec = importlib.util.spec_from_file_location("refresh_material_names", script_path)
refresh = importlib.util.module_from_spec(spec)
spec.loader.exec_module(refresh)
class MaterialNameCacheTests(unittest.TestCase):
def setUp(self) -> None:
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.path = Path(self.temp.name) / "another-checkout/data/material_names.json"
cache.load_material_names.cache_clear()
self.addCleanup(cache.load_material_names.cache_clear)
def test_generated_cache_is_portable_and_contains_only_name_lookup_data(self) -> None:
rows = [
{"FNumber": "20.40.00001", "FName": "编码器", "FMaterialId": 123, "FSpecification": "PRIVATE_MARKER"},
{"FNumber": "20.40.00001", "FName": "编码器", "FMaterialId": 456},
{"FNumber": "20.50.00002", "FName": "轴承"},
]
payload = refresh.write_cache(rows, "2026-10-09T10:13:22+08:00", self.path)
self.assertEqual(cache.load_material_names(self.path), {"20.40.00001": "编码器", "20.50.00002": "轴承"})
self.assertEqual(payload["source"]["record_count"], 3)
self.assertEqual(payload["material_count"], 2)
content = self.path.read_text()
for unwanted in ("FMaterialId", "FSpecification", "PRIVATE_MARKER", "/Users/"):
self.assertNotIn(unwanted, content)
def test_empty_invalid_or_conflicting_refresh_preserves_existing_cache(self) -> None:
refresh.write_cache([{"FNumber": "A", "FName": "原物料"}], "2026-10-09T10:00:00+08:00", self.path)
previous = self.path.read_bytes()
for rows in (
[], [{"FNumber": "A", "FName": ""}],
[{"FNumber": "A", "FName": "名称1"}, {"FNumber": "A", "FName": "名称2"}],
):
with self.subTest(rows=rows):
with self.assertRaises(cache.MaterialNameCacheError):
refresh.write_cache(rows, "2026-10-09T11:00:00+08:00", self.path)
self.assertEqual(self.path.read_bytes(), previous)
def test_cache_integrity_is_checked_and_loaded_once_until_restart(self) -> None:
refresh.write_cache([{"FNumber": "A", "FName": "编码器"}], "2026-10-09T10:00:00+08:00", self.path)
self.assertEqual(cache.load_material_names(self.path)["A"], "编码器")
payload = json.loads(self.path.read_text())
payload["materials"]["A"] = "篡改名称"
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, "完整性校验失败"):
cache.load_material_names(self.path)
def test_reference_export_rejects_filtered_or_incomplete_source(self) -> None:
rows = [{"FNumber": "A", "FName": "编码器"}]
payload = {
"form_id": "BD_MATERIAL", "filter": "", "records": rows,
"record_count": len(rows), "generated_at": "2026-10-09T10:00:00+08:00",
"content_sha256": hashlib.sha256(json.dumps(rows, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()).hexdigest(),
}
source = Path(self.temp.name) / "source.json"
source.write_text(json.dumps(payload))
self.assertEqual(refresh.read_reference(source)[0], rows)
for field, value in (("filter", "FForbidStatus='A'"), ("record_count", 2), ("content_sha256", "wrong")):
with self.subTest(field=field):
source.write_text(json.dumps({**payload, field: value}))
with self.assertRaises(cache.MaterialNameCacheError):
refresh.read_reference(source)
def test_live_refresh_pages_two_fields_only_and_rejects_repeated_pages(self) -> None:
full_page = [[f"code-{i:04}", "编码器"] for i in range(2000)]
query = mock.Mock(side_effect=[full_page, [["last-code", "轴承"]]])
client = SimpleNamespace(
LOCAL_CREDENTIALS_PATH=Path("credentials.local.json"),
build_opener=mock.Mock(return_value=object()), login=mock.Mock(),
execute_list_query=query,
)
fake_spec = SimpleNamespace(loader=SimpleNamespace(exec_module=mock.Mock()))
with (
mock.patch.object(refresh.importlib.util, "spec_from_file_location", return_value=fake_spec),
mock.patch.object(refresh.importlib.util, "module_from_spec", return_value=client),
mock.patch("builtins.print"),
):
rows, _ = refresh.query_materials(Path("/approved/source/91_Scripts/kingdee_openapi/kingdee_query.py"))
self.assertEqual(len(rows), 2001)
self.assertEqual([call.args[1].start_row for call in query.call_args_list], [0, 2000])
for call in query.call_args_list:
args = call.args[1]
self.assertEqual(args.fields, "FNumber,FName")
self.assertEqual(args.filter, "")
self.assertTrue(args.no_audit_log)
query.side_effect = [full_page, full_page]
with self.assertRaisesRegex(cache.MaterialNameCacheError, "分页重复"):
refresh.query_materials(Path("/approved/source/91_Scripts/kingdee_openapi/kingdee_query.py"))
if __name__ == "__main__":
unittest.main()