elc-invoice-engine-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cli/__init__.py +0 -0
- cli/auth.py +44 -0
- cli/client.py +53 -0
- cli/commands/__init__.py +0 -0
- cli/commands/describe.py +138 -0
- cli/commands/exchange.py +82 -0
- cli/commands/export.py +329 -0
- cli/commands/invoice.py +268 -0
- cli/commands/invoice_doc.py +104 -0
- cli/commands/login.py +82 -0
- cli/commands/party.py +72 -0
- cli/commands/product.py +67 -0
- cli/commands/tax.py +67 -0
- cli/config.py +28 -0
- cli/export/__init__.py +0 -0
- cli/export/excel_writer.py +110 -0
- cli/export/mapping_store.py +46 -0
- cli/export/xml_parser.py +50 -0
- cli/main.py +29 -0
- cli/output.py +44 -0
- cli/session.py +36 -0
- cli/token_store.py +35 -0
- elc_invoice_engine_cli-0.1.0.dist-info/METADATA +383 -0
- elc_invoice_engine_cli-0.1.0.dist-info/RECORD +27 -0
- elc_invoice_engine_cli-0.1.0.dist-info/WHEEL +5 -0
- elc_invoice_engine_cli-0.1.0.dist-info/entry_points.txt +2 -0
- elc_invoice_engine_cli-0.1.0.dist-info/top_level.txt +1 -0
cli/config.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from dotenv import load_dotenv
|
|
4
|
+
|
|
5
|
+
# 从工程根目录的 .env.local → .env 依次加载
|
|
6
|
+
_root = Path(__file__).parent.parent
|
|
7
|
+
load_dotenv(_root / ".env.local")
|
|
8
|
+
load_dotenv(_root / ".env")
|
|
9
|
+
|
|
10
|
+
BASE_URL: str = os.getenv("BASE_URL", "http://localhost:12007/xm-demo").rstrip("/")
|
|
11
|
+
REQUEST_TIMEOUT: int = int(os.getenv("REQUEST_TIMEOUT", "30"))
|
|
12
|
+
|
|
13
|
+
SSO_CONFIG: dict = {
|
|
14
|
+
"app_id": os.getenv("SSO_APP_ID", "sso"),
|
|
15
|
+
"secret": os.getenv("SSO_SECRET", ""),
|
|
16
|
+
"domain": os.getenv("SSO_DOMAIN", "kingdee-fpy"),
|
|
17
|
+
"mobile": os.getenv("SSO_MOBILE", ""),
|
|
18
|
+
"username": os.getenv("SSO_USERNAME", ""),
|
|
19
|
+
"work_number": os.getenv("SSO_WORK_NUMBER", ""),
|
|
20
|
+
"org_num": os.getenv("SSO_ORG_NUM", ""),
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
DEFAULT_HEADERS: dict = {
|
|
24
|
+
"Content-Type": "application/json",
|
|
25
|
+
"Accept-Language": "zh-CN",
|
|
26
|
+
"Authorization": "",
|
|
27
|
+
"X-Company-ID": os.getenv("X_COMPANY_ID", ""),
|
|
28
|
+
}
|
cli/export/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Excel 模版填写:将映射后的发票数据写入 openpyxl workbook。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from openpyxl import load_workbook
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def write_excel(
|
|
9
|
+
invoices: list[dict],
|
|
10
|
+
template_path: str,
|
|
11
|
+
output_path: str,
|
|
12
|
+
mapping: dict,
|
|
13
|
+
) -> int:
|
|
14
|
+
"""
|
|
15
|
+
将发票数据按映射表写入 Excel 模版。
|
|
16
|
+
|
|
17
|
+
invoices: 每个元素包含:
|
|
18
|
+
- _header: {col_name: value} 表头信息字段
|
|
19
|
+
- _lines: [{col_name: value}, ...] 行项目字段
|
|
20
|
+
mapping: load_mapping() 返回的映射表
|
|
21
|
+
返回写入的发票数量。
|
|
22
|
+
"""
|
|
23
|
+
wb = load_workbook(template_path)
|
|
24
|
+
header_ws = wb["表头信息"]
|
|
25
|
+
line_ws = wb["商品详情"]
|
|
26
|
+
|
|
27
|
+
# 读取模版列顺序(第1行英文列名)
|
|
28
|
+
header_cols = [c.value for c in header_ws[1]]
|
|
29
|
+
line_cols = [c.value for c in line_ws[1]]
|
|
30
|
+
|
|
31
|
+
header_row = 3 # 第1行英文列名,第2行中文说明,第3行起写数据
|
|
32
|
+
line_row = 3
|
|
33
|
+
|
|
34
|
+
for inv in invoices:
|
|
35
|
+
flat = inv.get("_header", {})
|
|
36
|
+
lines = inv.get("_lines", [])
|
|
37
|
+
|
|
38
|
+
# 写表头信息行
|
|
39
|
+
for col_idx, col_name in enumerate(header_cols, 1):
|
|
40
|
+
if col_name:
|
|
41
|
+
header_ws.cell(row=header_row, column=col_idx, value=flat.get(col_name))
|
|
42
|
+
header_row += 1
|
|
43
|
+
|
|
44
|
+
# 写商品详情行
|
|
45
|
+
for line in lines:
|
|
46
|
+
for col_idx, col_name in enumerate(line_cols, 1):
|
|
47
|
+
if col_name:
|
|
48
|
+
line_ws.cell(row=line_row, column=col_idx, value=line.get(col_name))
|
|
49
|
+
line_row += 1
|
|
50
|
+
|
|
51
|
+
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
wb.save(output_path)
|
|
53
|
+
return len(invoices)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def apply_mapping(invoice_json: dict, xml_fields: dict, mapping: dict) -> dict:
|
|
57
|
+
"""
|
|
58
|
+
将一条发票的 JSON + XML 字段按映射表合并为 {col_name: value} 扁平字典。
|
|
59
|
+
返回 {"_header": {...}, "_lines": [{...}, ...]}
|
|
60
|
+
"""
|
|
61
|
+
header_map = mapping.get("header_mapping", {})
|
|
62
|
+
line_map = mapping.get("line_mapping", {})
|
|
63
|
+
|
|
64
|
+
# 表头字段
|
|
65
|
+
header_flat: dict = {}
|
|
66
|
+
for col, rule in header_map.items():
|
|
67
|
+
source = rule.get("source")
|
|
68
|
+
if source == "json":
|
|
69
|
+
header_flat[col] = _get_nested(invoice_json, rule.get("path", ""))
|
|
70
|
+
elif source == "xml":
|
|
71
|
+
header_flat[col] = xml_fields.get(col)
|
|
72
|
+
# source == "none" 留空
|
|
73
|
+
|
|
74
|
+
# 行项目字段
|
|
75
|
+
lines_out: list[dict] = []
|
|
76
|
+
raw_lines = invoice_json.get("lines", [])
|
|
77
|
+
for raw_line in raw_lines:
|
|
78
|
+
line_flat: dict = {}
|
|
79
|
+
for col, rule in line_map.items():
|
|
80
|
+
source = rule.get("source")
|
|
81
|
+
if source == "json":
|
|
82
|
+
line_flat[col] = _get_nested(raw_line, rule.get("path", ""))
|
|
83
|
+
elif source == "xml":
|
|
84
|
+
line_flat[col] = xml_fields.get(col)
|
|
85
|
+
# invoiceNo 注入到每行
|
|
86
|
+
line_flat["*invoiceNo"] = invoice_json.get("invoiceNo")
|
|
87
|
+
lines_out.append(line_flat)
|
|
88
|
+
|
|
89
|
+
return {"_header": header_flat, "_lines": lines_out}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _get_nested(obj: dict, path: str) -> object:
|
|
93
|
+
"""支持点分隔路径,如 'taxInfo.0.taxRate'。"""
|
|
94
|
+
if not path:
|
|
95
|
+
return None
|
|
96
|
+
parts = path.split(".")
|
|
97
|
+
cur = obj
|
|
98
|
+
for p in parts:
|
|
99
|
+
if cur is None:
|
|
100
|
+
return None
|
|
101
|
+
if isinstance(cur, list):
|
|
102
|
+
try:
|
|
103
|
+
cur = cur[int(p)]
|
|
104
|
+
except (ValueError, IndexError):
|
|
105
|
+
return None
|
|
106
|
+
elif isinstance(cur, dict):
|
|
107
|
+
cur = cur.get(p)
|
|
108
|
+
else:
|
|
109
|
+
return None
|
|
110
|
+
return cur
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""映射表本地持久化:读写 ~/.elc/mappings/<template_md5>.json。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
MAPPINGS_DIR = Path.home() / ".elc" / "mappings"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _md5(template_path: str) -> str:
|
|
13
|
+
return hashlib.md5(Path(template_path).read_bytes()).hexdigest()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_mapping(template_path: str) -> dict | None:
|
|
17
|
+
"""读取缓存映射表;模版内容变了(MD5 不同)返回 None。"""
|
|
18
|
+
MAPPINGS_DIR.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
key = _md5(template_path)
|
|
20
|
+
path = MAPPINGS_DIR / f"{key}.json"
|
|
21
|
+
if not path.exists():
|
|
22
|
+
return None
|
|
23
|
+
try:
|
|
24
|
+
return json.loads(path.read_text())
|
|
25
|
+
except Exception:
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def save_mapping(template_path: str, mapping: dict) -> None:
|
|
30
|
+
"""保存映射表,附带元信息。"""
|
|
31
|
+
MAPPINGS_DIR.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
key = _md5(template_path)
|
|
33
|
+
payload = {
|
|
34
|
+
"template_path": str(Path(template_path).resolve()),
|
|
35
|
+
"template_md5": key,
|
|
36
|
+
"created_at": datetime.now().isoformat(timespec="seconds"),
|
|
37
|
+
**mapping,
|
|
38
|
+
}
|
|
39
|
+
(MAPPINGS_DIR / f"{key}.json").write_text(
|
|
40
|
+
json.dumps(payload, ensure_ascii=False, indent=2)
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def mapping_exists(template_path: str) -> bool:
|
|
45
|
+
key = _md5(template_path)
|
|
46
|
+
return (MAPPINGS_DIR / f"{key}.json").exists()
|
cli/export/xml_parser.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""KDUBL XML 解析:按映射表中的 XPath 批量提取字段。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from lxml import etree
|
|
5
|
+
|
|
6
|
+
NSMAP = {
|
|
7
|
+
"cbc": "urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2",
|
|
8
|
+
"cac": "urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2",
|
|
9
|
+
"ext": "urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2",
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _xpath_text(root: etree._Element, xpath: str) -> str | None:
|
|
14
|
+
try:
|
|
15
|
+
nodes = root.xpath(xpath, namespaces=NSMAP)
|
|
16
|
+
if not nodes:
|
|
17
|
+
return None
|
|
18
|
+
node = nodes[0]
|
|
19
|
+
return node.text.strip() if hasattr(node, "text") and node.text else str(node).strip()
|
|
20
|
+
except Exception:
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def extract_fields(xml_bytes: bytes, xpath_map: dict[str, str]) -> dict[str, str | None]:
|
|
25
|
+
"""按 xpath_map 批量提取字段,返回 {col_name: value}。"""
|
|
26
|
+
root = etree.fromstring(xml_bytes)
|
|
27
|
+
return {col: _xpath_text(root, xpath) for col, xpath in xpath_map.items()}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def flatten_xml_fields(xml_bytes: bytes) -> list[dict]:
|
|
31
|
+
"""提取 XML 中所有叶子节点的 XPath + 值,用于 AI 推断映射时的上下文。"""
|
|
32
|
+
root = etree.fromstring(xml_bytes)
|
|
33
|
+
results = []
|
|
34
|
+
|
|
35
|
+
def _walk(node: etree._Element, path: str) -> None:
|
|
36
|
+
tag = etree.QName(node.tag).localname if node.tag else ""
|
|
37
|
+
current_path = f"{path}/{tag}" if path else tag
|
|
38
|
+
# 属性
|
|
39
|
+
for attr_name, attr_val in node.attrib.items():
|
|
40
|
+
attr_local = etree.QName(attr_name).localname if "{" in attr_name else attr_name
|
|
41
|
+
results.append({"xpath": f"{current_path}[@{attr_local}]", "value": attr_val})
|
|
42
|
+
# 文本值
|
|
43
|
+
text = (node.text or "").strip()
|
|
44
|
+
if text:
|
|
45
|
+
results.append({"xpath": current_path, "value": text})
|
|
46
|
+
for child in node:
|
|
47
|
+
_walk(child, current_path)
|
|
48
|
+
|
|
49
|
+
_walk(root, "")
|
|
50
|
+
return results
|
cli/main.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""CLI 主入口。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
from cli.commands import describe, exchange, export, invoice, invoice_doc, login, party, product, tax
|
|
6
|
+
|
|
7
|
+
app = typer.Typer(
|
|
8
|
+
name="elc",
|
|
9
|
+
help="ELC Invoice Engine CLI — V2 接口命令行工具",
|
|
10
|
+
no_args_is_help=True,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
# 认证
|
|
14
|
+
app.add_typer(login.app, name="login")
|
|
15
|
+
|
|
16
|
+
# 上下文(AI 使用)
|
|
17
|
+
app.add_typer(describe.app, name="describe")
|
|
18
|
+
|
|
19
|
+
# 核心业务
|
|
20
|
+
app.add_typer(invoice.app, name="invoice-request")
|
|
21
|
+
app.add_typer(invoice_doc.app, name="invoice")
|
|
22
|
+
app.add_typer(party.app, name="party")
|
|
23
|
+
app.add_typer(product.app, name="product")
|
|
24
|
+
app.add_typer(tax.app, name="tax")
|
|
25
|
+
app.add_typer(exchange.app, name="exchange")
|
|
26
|
+
app.add_typer(export.app, name="export")
|
|
27
|
+
|
|
28
|
+
if __name__ == "__main__":
|
|
29
|
+
app()
|
cli/output.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""输出格式化:table / json / raw。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json as _json
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
console = Console()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def print_json(data: dict | list) -> None:
|
|
13
|
+
console.print_json(_json.dumps(data, ensure_ascii=False, indent=2))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def print_table(rows: list[dict], columns: list[str] | None = None) -> None:
|
|
17
|
+
if not rows:
|
|
18
|
+
typer.echo("(无数据)")
|
|
19
|
+
return
|
|
20
|
+
cols = columns or list(rows[0].keys())
|
|
21
|
+
t = Table(show_header=True, header_style="bold cyan")
|
|
22
|
+
for c in cols:
|
|
23
|
+
t.add_column(c)
|
|
24
|
+
for row in rows:
|
|
25
|
+
t.add_row(*[str(row.get(c, "")) for c in cols])
|
|
26
|
+
console.print(t)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def print_result(status: int, body: dict, output: str = "table",
|
|
30
|
+
rows_key: str = "data", columns: list[str] | None = None) -> None:
|
|
31
|
+
"""统一打印 V2 响应:output 为 'table'、'json' 或 'raw'。"""
|
|
32
|
+
if output == "json":
|
|
33
|
+
print_json(body)
|
|
34
|
+
return
|
|
35
|
+
if status >= 400:
|
|
36
|
+
console.print(f"[red]HTTP {status}[/red] {body}")
|
|
37
|
+
return
|
|
38
|
+
data = body.get(rows_key, body)
|
|
39
|
+
if output == "table" and isinstance(data, list):
|
|
40
|
+
print_table(data, columns)
|
|
41
|
+
elif output == "table" and isinstance(data, dict):
|
|
42
|
+
print_table([data], columns)
|
|
43
|
+
else:
|
|
44
|
+
console.print(data)
|
cli/session.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""懒加载 ApiClient:优先读取本地缓存 token,过期才重新登录。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from cli.token_store import load_token, save_token
|
|
7
|
+
from cli.auth import fetch_token
|
|
8
|
+
from cli.client import ApiClient
|
|
9
|
+
|
|
10
|
+
_console = Console()
|
|
11
|
+
_client: ApiClient | None = None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_client() -> ApiClient:
|
|
15
|
+
global _client
|
|
16
|
+
if _client is not None:
|
|
17
|
+
return _client
|
|
18
|
+
|
|
19
|
+
# 优先读本地缓存
|
|
20
|
+
token = load_token()
|
|
21
|
+
if not token:
|
|
22
|
+
try:
|
|
23
|
+
token, expires_in = fetch_token()
|
|
24
|
+
save_token(token, expires_in)
|
|
25
|
+
except Exception as e:
|
|
26
|
+
_console.print(f"[red]认证失败:{e}[/red]")
|
|
27
|
+
_console.print("[yellow]提示:请先运行 elc login 配置凭证[/yellow]")
|
|
28
|
+
raise typer.Exit(1)
|
|
29
|
+
|
|
30
|
+
_client = ApiClient(token=token)
|
|
31
|
+
return _client
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def reset_client() -> None:
|
|
35
|
+
global _client
|
|
36
|
+
_client = None
|
cli/token_store.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Token 本地持久化:读写 ~/.elc/token.json。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
_TOKEN_FILE = Path.home() / ".elc" / "token.json"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def save_token(access_token: str, expires_in: int) -> None:
|
|
12
|
+
_TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
13
|
+
payload = {
|
|
14
|
+
"access_token": access_token,
|
|
15
|
+
"expires_at": int(time.time()) + expires_in - 60, # 提前 60s 过期
|
|
16
|
+
}
|
|
17
|
+
_TOKEN_FILE.write_text(json.dumps(payload, indent=2))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_token() -> str | None:
|
|
21
|
+
"""读取本地 token;过期或不存在返回 None。"""
|
|
22
|
+
if not _TOKEN_FILE.exists():
|
|
23
|
+
return None
|
|
24
|
+
try:
|
|
25
|
+
payload = json.loads(_TOKEN_FILE.read_text())
|
|
26
|
+
if time.time() < payload.get("expires_at", 0):
|
|
27
|
+
return payload["access_token"]
|
|
28
|
+
except Exception:
|
|
29
|
+
pass
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def clear_token() -> None:
|
|
34
|
+
if _TOKEN_FILE.exists():
|
|
35
|
+
_TOKEN_FILE.unlink()
|