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/__init__.py
ADDED
|
File without changes
|
cli/auth.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""SSO 认证:MD5 签名获取 token。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import time
|
|
6
|
+
import httpx
|
|
7
|
+
from cli.config import BASE_URL, SSO_CONFIG, REQUEST_TIMEOUT
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _sign(app_id: str, secret: str, timestamp: str) -> str:
|
|
11
|
+
return hashlib.md5(f"{app_id}{secret}{timestamp}".encode()).hexdigest()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def fetch_token() -> tuple[str, int]:
|
|
15
|
+
"""调用 /api/sso/kingdee/token,返回 (access_token, expires_in)。"""
|
|
16
|
+
app_id = SSO_CONFIG["app_id"]
|
|
17
|
+
secret = SSO_CONFIG["secret"]
|
|
18
|
+
timestamp = str(int(time.time() * 1000))
|
|
19
|
+
|
|
20
|
+
body = {
|
|
21
|
+
"domain": SSO_CONFIG["domain"],
|
|
22
|
+
"appId": app_id,
|
|
23
|
+
"timestamp": timestamp,
|
|
24
|
+
"sign": _sign(app_id, secret, timestamp),
|
|
25
|
+
"mobile": SSO_CONFIG["mobile"],
|
|
26
|
+
"userName": SSO_CONFIG["username"],
|
|
27
|
+
"workNumber": SSO_CONFIG["work_number"],
|
|
28
|
+
"orgNum": SSO_CONFIG["org_num"],
|
|
29
|
+
}
|
|
30
|
+
body = {k: v for k, v in body.items() if v}
|
|
31
|
+
|
|
32
|
+
url = f"{BASE_URL}/api/sso/kingdee/token"
|
|
33
|
+
resp = httpx.post(url, json=body, timeout=REQUEST_TIMEOUT)
|
|
34
|
+
resp.raise_for_status()
|
|
35
|
+
|
|
36
|
+
result = resp.json()
|
|
37
|
+
data = result.get("data") or {}
|
|
38
|
+
token = data.get("accessToken")
|
|
39
|
+
expires_in = int(data.get("expiresIn", 7200))
|
|
40
|
+
|
|
41
|
+
if not token:
|
|
42
|
+
raise RuntimeError(f"SSO 登录失败:{result}")
|
|
43
|
+
|
|
44
|
+
return token, expires_in
|
cli/client.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""HTTP 客户端:封装 httpx,统一注入认证头和 requestId。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import uuid
|
|
5
|
+
import httpx
|
|
6
|
+
from cli.config import BASE_URL, DEFAULT_HEADERS, REQUEST_TIMEOUT
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ApiClient:
|
|
10
|
+
def __init__(self, token: str = ""):
|
|
11
|
+
self.base_url = BASE_URL
|
|
12
|
+
self.headers = dict(DEFAULT_HEADERS)
|
|
13
|
+
if token:
|
|
14
|
+
self.headers["Authorization"] = f"Bearer {token}"
|
|
15
|
+
self._client = httpx.Client(timeout=REQUEST_TIMEOUT)
|
|
16
|
+
|
|
17
|
+
def _url(self, path: str) -> str:
|
|
18
|
+
return f"{self.base_url}/{path.lstrip('/')}"
|
|
19
|
+
|
|
20
|
+
def _req_headers(self) -> dict:
|
|
21
|
+
return {**self.headers, "X-Request-Id": uuid.uuid4().hex}
|
|
22
|
+
|
|
23
|
+
def get(self, path: str, params: dict = None) -> tuple[int, dict]:
|
|
24
|
+
r = self._client.get(self._url(path), headers=self._req_headers(), params=params or {})
|
|
25
|
+
return r.status_code, self._body(r)
|
|
26
|
+
|
|
27
|
+
def post(self, path: str, json: dict | list = None, params: dict = None) -> tuple[int, dict]:
|
|
28
|
+
r = self._client.post(self._url(path), headers=self._req_headers(), json=json, params=params or {})
|
|
29
|
+
return r.status_code, self._body(r)
|
|
30
|
+
|
|
31
|
+
def put(self, path: str, json: dict | list = None) -> tuple[int, dict]:
|
|
32
|
+
r = self._client.put(self._url(path), headers=self._req_headers(), json=json)
|
|
33
|
+
return r.status_code, self._body(r)
|
|
34
|
+
|
|
35
|
+
def delete(self, path: str, params: dict = None) -> tuple[int, dict]:
|
|
36
|
+
r = self._client.delete(self._url(path), headers=self._req_headers(), params=params or {})
|
|
37
|
+
return r.status_code, self._body(r)
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def _body(r: httpx.Response) -> dict:
|
|
41
|
+
try:
|
|
42
|
+
return r.json()
|
|
43
|
+
except Exception:
|
|
44
|
+
return {"raw": r.text}
|
|
45
|
+
|
|
46
|
+
def close(self) -> None:
|
|
47
|
+
self._client.close()
|
|
48
|
+
|
|
49
|
+
def __enter__(self):
|
|
50
|
+
return self
|
|
51
|
+
|
|
52
|
+
def __exit__(self, *_):
|
|
53
|
+
self.close()
|
cli/commands/__init__.py
ADDED
|
File without changes
|
cli/commands/describe.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""describe 命令:输出当前 CLI 环境上下文,供 AI 使用。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
from cli.config import BASE_URL, SSO_CONFIG, DEFAULT_HEADERS
|
|
12
|
+
from cli.token_store import load_token
|
|
13
|
+
from cli.export.mapping_store import MAPPINGS_DIR
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(help="输出 CLI 环境上下文(供 AI 使用)")
|
|
16
|
+
console = Console()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@app.callback(invoke_without_command=True)
|
|
20
|
+
def describe(
|
|
21
|
+
ctx: typer.Context,
|
|
22
|
+
output: str = typer.Option("json", "-o", help="输出格式:json / text"),
|
|
23
|
+
):
|
|
24
|
+
"""输出当前 CLI 环境、命令说明、接口行为和已知问题,供 AI 建立上下文。
|
|
25
|
+
|
|
26
|
+
在开始任何 ELC CLI 操作前,先运行此命令让 AI 了解当前环境。
|
|
27
|
+
"""
|
|
28
|
+
if ctx.invoked_subcommand is not None:
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
token = load_token()
|
|
32
|
+
logged_in = token is not None
|
|
33
|
+
|
|
34
|
+
# 已缓存的映射表
|
|
35
|
+
mappings = []
|
|
36
|
+
if MAPPINGS_DIR.exists():
|
|
37
|
+
for f in MAPPINGS_DIR.glob("*.json"):
|
|
38
|
+
try:
|
|
39
|
+
m = json.loads(f.read_text())
|
|
40
|
+
mappings.append({
|
|
41
|
+
"template": m.get("template_path", ""),
|
|
42
|
+
"created_at": m.get("created_at", ""),
|
|
43
|
+
"header_fields": len(m.get("header_mapping", {})),
|
|
44
|
+
"line_fields": len(m.get("line_mapping", {})),
|
|
45
|
+
})
|
|
46
|
+
except Exception:
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
ctx_data = {
|
|
50
|
+
"env": {
|
|
51
|
+
"base_url": BASE_URL,
|
|
52
|
+
"company_id": DEFAULT_HEADERS.get("X-Company-ID", ""),
|
|
53
|
+
"domain": SSO_CONFIG.get("domain", ""),
|
|
54
|
+
"logged_in": logged_in,
|
|
55
|
+
"token_file": str(Path.home() / ".elc" / "token.json"),
|
|
56
|
+
},
|
|
57
|
+
"cached_mappings": mappings,
|
|
58
|
+
"commands": {
|
|
59
|
+
"elc login": "交互式登录,token 保存到 ~/.elc/token.json",
|
|
60
|
+
"elc login status": "查看登录状态",
|
|
61
|
+
"elc login logout": "退出登录",
|
|
62
|
+
"elc invoice-request list": "查询开票申请单列表,支持 --status / --source-system / --month 过滤",
|
|
63
|
+
"elc invoice-request get <id>": "查询单条申请单详情(注意:/v2 单条接口有时返回 500,用 list 替代)",
|
|
64
|
+
"elc invoice-request create -f <xml>": "创建申请单,上传 KDUBL XML,自动生成幂等键",
|
|
65
|
+
"elc invoice-request update <id> -f <xml>": "更新申请单(状态需为 draft/validate_failed/pending)",
|
|
66
|
+
"elc invoice-request void <id>": "作废申请单",
|
|
67
|
+
"elc invoice-request issue <id>":"触发开票(状态需为 PENDING_INVOICE)",
|
|
68
|
+
"elc invoice-request invoices <id>": "查询申请单下的发票列表",
|
|
69
|
+
"elc invoice-request match-cn-create -f <xml>": "创建 Credit Note 蓝冲匹配任务",
|
|
70
|
+
"elc invoice-request match-cn-run <id>": "触发蓝冲匹配",
|
|
71
|
+
"elc invoice-request match-cn-results <id>": "查询匹配结果",
|
|
72
|
+
"elc invoice-request match-cn-unlink <id>": "解除匹配绑定",
|
|
73
|
+
"elc invoice get <id>": "查询发票详情",
|
|
74
|
+
"elc invoice file-link <id>": "获取发票文件下载链接(--type Humanreadable/Target/Source)",
|
|
75
|
+
"elc invoice download <id>": "直接下载发票文件到本地(--type Source 下载 KDUBL XML)",
|
|
76
|
+
"elc invoice cancel <id>": "取消发票(仅 MY,72小时内)",
|
|
77
|
+
"elc party list": "查询注册主体列表",
|
|
78
|
+
"elc party upsert -f <json>": "创建或更新注册主体",
|
|
79
|
+
"elc product list": "查询商品列表",
|
|
80
|
+
"elc product upsert -f <json>": "创建或更新商品",
|
|
81
|
+
"elc tax list": "查询税目列表",
|
|
82
|
+
"elc exchange list": "查询汇率列表",
|
|
83
|
+
"elc exchange create": "新增汇率",
|
|
84
|
+
"elc export init -t <tmpl>": "采集模版列名和样本数据,供 AI 推断字段映射",
|
|
85
|
+
"elc export save-mapping -t <tmpl> -f <json>": "保存 AI 推断的字段映射到本地缓存",
|
|
86
|
+
"elc export invoice -t <tmpl>": "导出发票数据到 Excel(需先 init + save-mapping)",
|
|
87
|
+
"elc describe": "输出本上下文(当前命令)",
|
|
88
|
+
},
|
|
89
|
+
"api_behaviors": {
|
|
90
|
+
"pagination": {
|
|
91
|
+
"type": "cursor",
|
|
92
|
+
"param": "cursor(整数,初始不传,后续传 nextCursor 的值)",
|
|
93
|
+
"page_size": "pageSize(服务端固定每页20条,设置其他值无效)",
|
|
94
|
+
"has_more": "hasMore: true 时继续翻页,false 时停止",
|
|
95
|
+
"example": "第一页不传 cursor,返回 nextCursor=20,第二页传 cursor=20",
|
|
96
|
+
},
|
|
97
|
+
"time_filter": {
|
|
98
|
+
"params": "updatedFrom / updatedTo(格式 YYYY-MM-DD)",
|
|
99
|
+
"note": "按申请单 updatedTime 过滤,不是 createdTime",
|
|
100
|
+
},
|
|
101
|
+
"invoice_status": {
|
|
102
|
+
"INVOICED_SUCCESS": "开票成功,有关联 invoices[]",
|
|
103
|
+
"VALIDATION_FAILED": "校验失败,查看 failureDetails",
|
|
104
|
+
"PENDING_INVOICE": "待开票,可调用 issue 触发",
|
|
105
|
+
"DRAFT": "草稿,可更新",
|
|
106
|
+
},
|
|
107
|
+
"source_xml": {
|
|
108
|
+
"available": "仅通过 /v2/invoice-requests(上传 KDUBL XML)创建的发票才有 Source 文件",
|
|
109
|
+
"endpoint": "GET /v1/invoices/file/{invoiceId}?filetype=Source&fileFormat=xml",
|
|
110
|
+
"error_403004":"No file found — 该发票不是通过 XML 上传创建的,无 Source 文件",
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
"known_issues": {
|
|
114
|
+
"header_case": "X-Company-ID(大写 ID),不是 X-Company-Id",
|
|
115
|
+
"get_single_ir": "GET /v2/invoice-requests/{id} 有时返回 500,改用 list + sourceId 过滤",
|
|
116
|
+
"issue_500": "issue 命令返回 500 时,检查申请单状态是否为 PENDING_INVOICE",
|
|
117
|
+
"xml_missing": "SIT 环境大部分测试数据无 Source XML(通过 JSON 方式创建),正式数据通过 XML 上传则有",
|
|
118
|
+
"max_batches": "export invoice 默认 --max-batches 500(每批20条),数据量大时适当调小",
|
|
119
|
+
"token_expiry": "token 有效期约 7.5 小时,过期后自动重新登录",
|
|
120
|
+
},
|
|
121
|
+
"export_workflow": {
|
|
122
|
+
"step1": "elc export init -t <template> -o /tmp/sample.json",
|
|
123
|
+
"step2": "读取 /tmp/sample.json,AI 推断字段映射,生成 mapping JSON",
|
|
124
|
+
"step3": "elc export save-mapping -t <template> -f /tmp/mapping.json",
|
|
125
|
+
"step4": "elc export invoice -t <template> --month YYYY-MM --company BU-XXXXX --out <dir>",
|
|
126
|
+
"note": "同一模版只需初始化一次,模版文件内容变更后需重新 init",
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if output == "json":
|
|
131
|
+
console.print_json(json.dumps(ctx_data, ensure_ascii=False, indent=2))
|
|
132
|
+
else:
|
|
133
|
+
console.print(f"[bold cyan]ELC CLI 环境上下文[/bold cyan]")
|
|
134
|
+
console.print(f"BASE_URL: {ctx_data['env']['base_url']}")
|
|
135
|
+
console.print(f"Company: {ctx_data['env']['company_id']}")
|
|
136
|
+
console.print(f"Logged in: {'✓' if logged_in else '✗ 请先运行 elc login'}")
|
|
137
|
+
console.print(f"\n[bold]已缓存映射表:[/bold] {len(mappings)} 个")
|
|
138
|
+
console.print(f"\n运行 elc describe -o json 获取完整上下文")
|
cli/commands/exchange.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""exchange-rates 命令组。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
from typing import Optional
|
|
6
|
+
from cli.session import get_client
|
|
7
|
+
from cli.output import print_result
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(help="汇率管理")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@app.command("list")
|
|
13
|
+
def exchange_list(
|
|
14
|
+
currency_from: Optional[str] = typer.Option(None, "--from", help="源货币代码"),
|
|
15
|
+
currency_to: Optional[str] = typer.Option(None, "--to", help="目标货币代码"),
|
|
16
|
+
page: int = typer.Option(1, help="页码"),
|
|
17
|
+
page_size: int = typer.Option(20, "--page-size", help="每页条数"),
|
|
18
|
+
output: str = typer.Option("table", "-o", help="输出格式:table / json"),
|
|
19
|
+
):
|
|
20
|
+
"""查询汇率列表。"""
|
|
21
|
+
params = {"pageNum": page, "pageSize": page_size}
|
|
22
|
+
if currency_from:
|
|
23
|
+
params["currencyFrom"] = currency_from
|
|
24
|
+
if currency_to:
|
|
25
|
+
params["currencyTo"] = currency_to
|
|
26
|
+
status, body = get_client().get("/api/exchange-rates/list", params=params)
|
|
27
|
+
print_result(status, body, output, rows_key="rows",
|
|
28
|
+
columns=["id", "currencyFrom", "currencyTo", "exchangeRate", "rateDate", "status"])
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@app.command("latest")
|
|
32
|
+
def exchange_latest(
|
|
33
|
+
currency_from: str = typer.Argument(..., help="源货币代码,如 USD"),
|
|
34
|
+
currency_to: str = typer.Argument(..., help="目标货币代码,如 CNY"),
|
|
35
|
+
output: str = typer.Option("table", "-o", help="输出格式:table / json"),
|
|
36
|
+
):
|
|
37
|
+
"""查询最新汇率。"""
|
|
38
|
+
status, body = get_client().get(
|
|
39
|
+
"/api/exchange-rates/latest",
|
|
40
|
+
params={"currencyFrom": currency_from, "currencyTo": currency_to},
|
|
41
|
+
)
|
|
42
|
+
print_result(status, body, output)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@app.command("create")
|
|
46
|
+
def exchange_create(
|
|
47
|
+
currency_from: str = typer.Option(..., "--from", help="源货币代码"),
|
|
48
|
+
currency_to: str = typer.Option(..., "--to", help="目标货币代码"),
|
|
49
|
+
rate: float = typer.Option(..., "--rate", help="汇率"),
|
|
50
|
+
rate_date: str = typer.Option(..., "--date", help="生效日期,格式 YYYY-MM-DD"),
|
|
51
|
+
output: str = typer.Option("json", "-o", help="输出格式:table / json"),
|
|
52
|
+
):
|
|
53
|
+
"""新增汇率记录。"""
|
|
54
|
+
payload = {
|
|
55
|
+
"currencyFrom": currency_from,
|
|
56
|
+
"currencyTo": currency_to,
|
|
57
|
+
"exchangeRate": rate,
|
|
58
|
+
"rateDate": rate_date,
|
|
59
|
+
"baseAmount": 1,
|
|
60
|
+
"rateSource": "MANUAL",
|
|
61
|
+
"status": 1,
|
|
62
|
+
}
|
|
63
|
+
status, body = get_client().post("/api/exchange-rates", json=payload)
|
|
64
|
+
print_result(status, body, output)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@app.command("delete")
|
|
68
|
+
def exchange_delete(
|
|
69
|
+
id: int = typer.Argument(..., help="汇率记录 ID"),
|
|
70
|
+
):
|
|
71
|
+
"""删除汇率记录。"""
|
|
72
|
+
status, body = get_client().delete(f"/api/exchange-rates/{id}")
|
|
73
|
+
print_result(status, body, "json")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@app.command("currencies")
|
|
77
|
+
def exchange_currencies(
|
|
78
|
+
output: str = typer.Option("table", "-o", help="输出格式:table / json"),
|
|
79
|
+
):
|
|
80
|
+
"""查询支持的货币代码列表。"""
|
|
81
|
+
status, body = get_client().get("/api/exchange-rates/currencies")
|
|
82
|
+
print_result(status, body, output)
|
cli/commands/export.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""export 命令组:elc export init / elc export save-mapping / elc export invoice。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import calendar
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from datetime import date, datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
import typer
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
|
|
17
|
+
from cli.config import BASE_URL, REQUEST_TIMEOUT
|
|
18
|
+
from cli.export.excel_writer import apply_mapping, write_excel
|
|
19
|
+
from cli.export.mapping_store import load_mapping, mapping_exists, save_mapping
|
|
20
|
+
from cli.export.xml_parser import extract_fields, flatten_xml_fields
|
|
21
|
+
from cli.session import get_client
|
|
22
|
+
|
|
23
|
+
app = typer.Typer(help="发票数据导出")
|
|
24
|
+
console = Console()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ── 工具函数 ──────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
def _read_template_cols(template_path: str) -> tuple[list[tuple], list[tuple]]:
|
|
30
|
+
"""读取模版两个 Sheet 的列名,返回 [(英文, 中文), ...]。"""
|
|
31
|
+
from openpyxl import load_workbook
|
|
32
|
+
wb = load_workbook(template_path, read_only=True)
|
|
33
|
+
|
|
34
|
+
def _pairs(ws) -> list[tuple[str, str]]:
|
|
35
|
+
row1 = [c.value or "" for c in list(ws.iter_rows(min_row=1, max_row=1))[0]]
|
|
36
|
+
row2 = [c.value or "" for c in list(ws.iter_rows(min_row=2, max_row=2))[0]]
|
|
37
|
+
return [(str(a), str(b)) for a, b in zip(row1, row2) if a]
|
|
38
|
+
|
|
39
|
+
return _pairs(wb["表头信息"]), _pairs(wb["商品详情"])
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _month_range(month_str: str) -> tuple[str, str]:
|
|
43
|
+
"""'2026-06' → ('2026-06-01', '2026-06-30')"""
|
|
44
|
+
d = datetime.strptime(month_str, "%Y-%m")
|
|
45
|
+
last_day = calendar.monthrange(d.year, d.month)[1]
|
|
46
|
+
return f"{d.year:04d}-{d.month:02d}-01", f"{d.year:04d}-{d.month:02d}-{last_day:02d}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _fetch_source_xml(invoice_id: str, token: str, company_id: str) -> bytes | None:
|
|
50
|
+
"""下载 KDUBL Source XML,返回原始字节。"""
|
|
51
|
+
headers = {
|
|
52
|
+
"Authorization": f"Bearer {token}",
|
|
53
|
+
"X-Company-Id": company_id,
|
|
54
|
+
}
|
|
55
|
+
url = f"{BASE_URL}/v1/invoices/file/{invoice_id}"
|
|
56
|
+
params = {"filetype": "Source", "fileFormat": "xml"}
|
|
57
|
+
try:
|
|
58
|
+
resp = httpx.get(url, headers=headers, params=params, timeout=REQUEST_TIMEOUT)
|
|
59
|
+
if resp.status_code == 200:
|
|
60
|
+
ct = resp.headers.get("content-type", "")
|
|
61
|
+
if "xml" in ct or resp.content.strip().startswith(b"<"):
|
|
62
|
+
return resp.content
|
|
63
|
+
except Exception:
|
|
64
|
+
pass
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _collect_invoices(month_str: str, max_batches: int = 500) -> list[dict]:
|
|
69
|
+
"""分页拉取本月 INVOICED_SUCCESS 的 invoice 对象列表。"""
|
|
70
|
+
client = get_client()
|
|
71
|
+
updated_from, updated_to = _month_range(month_str)
|
|
72
|
+
|
|
73
|
+
all_invoices: list[dict] = []
|
|
74
|
+
seen_invoice_ids: set = set()
|
|
75
|
+
cursor = None
|
|
76
|
+
batch = 0
|
|
77
|
+
|
|
78
|
+
while batch < max_batches:
|
|
79
|
+
params: dict = {
|
|
80
|
+
"pageSize": 20,
|
|
81
|
+
"updatedFrom": updated_from,
|
|
82
|
+
"updatedTo": updated_to,
|
|
83
|
+
}
|
|
84
|
+
if cursor is not None:
|
|
85
|
+
params["cursor"] = cursor
|
|
86
|
+
|
|
87
|
+
status, body = client.get("/v2/invoice-requests", params=params)
|
|
88
|
+
if status != 200 or body.get("code") not in (None, "0000"):
|
|
89
|
+
console.print(f"[red]查询失败:{body}[/red]")
|
|
90
|
+
break
|
|
91
|
+
|
|
92
|
+
data = body.get("data") or {}
|
|
93
|
+
requests = data.get("invoiceRequests", [])
|
|
94
|
+
batch += 1
|
|
95
|
+
|
|
96
|
+
for ir in requests:
|
|
97
|
+
if ir.get("invoiceRequestStatus") != "INVOICED_SUCCESS":
|
|
98
|
+
continue
|
|
99
|
+
for inv in ir.get("invoices", []):
|
|
100
|
+
if inv.get("issueStatus") == "SUCCESS":
|
|
101
|
+
inv_id = inv["invoiceId"]
|
|
102
|
+
if inv_id not in seen_invoice_ids:
|
|
103
|
+
seen_invoice_ids.add(inv_id)
|
|
104
|
+
all_invoices.append({
|
|
105
|
+
"invoiceId": inv_id,
|
|
106
|
+
"sourceSystem": ir.get("sourceSystem", ""),
|
|
107
|
+
"invoiceRequestId": ir.get("invoiceRequestId", ""),
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
if not data.get("hasMore"):
|
|
111
|
+
break
|
|
112
|
+
|
|
113
|
+
next_cursor = data.get("nextCursor")
|
|
114
|
+
if next_cursor is None or next_cursor == cursor:
|
|
115
|
+
break
|
|
116
|
+
cursor = next_cursor
|
|
117
|
+
|
|
118
|
+
console.print(f" 共查询 {batch} 批,找到 {len(all_invoices)} 张不重复发票")
|
|
119
|
+
|
|
120
|
+
return all_invoices
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ── 命令:init(采集样本数据,输出给 Claude 推断)────────────────────────────
|
|
124
|
+
|
|
125
|
+
@app.command("init")
|
|
126
|
+
def export_init(
|
|
127
|
+
template: str = typer.Option(..., "--template", "-t", help="Excel 模版路径"),
|
|
128
|
+
company: Optional[str] = typer.Option(None, "--company", "-c", help="公司 ID"),
|
|
129
|
+
output: Optional[str] = typer.Option(None, "--output", "-o",
|
|
130
|
+
help="将样本数据输出到文件(默认打印到终端)"),
|
|
131
|
+
):
|
|
132
|
+
"""采集模版列名和真实发票样本,输出给 Claude 推断字段映射。
|
|
133
|
+
|
|
134
|
+
使用步骤:
|
|
135
|
+
1. 运行此命令,将输出复制给 Claude
|
|
136
|
+
2. Claude 推断映射后生成 JSON
|
|
137
|
+
3. 运行 elc export save-mapping 保存映射
|
|
138
|
+
"""
|
|
139
|
+
template = str(Path(template).expanduser().resolve())
|
|
140
|
+
if not Path(template).exists():
|
|
141
|
+
console.print(f"[red]模版文件不存在:{template}[/red]")
|
|
142
|
+
raise typer.Exit(1)
|
|
143
|
+
|
|
144
|
+
company = company or os.environ.get("X_COMPANY_ID", "")
|
|
145
|
+
|
|
146
|
+
console.print("[cyan]正在读取模版列名...[/cyan]")
|
|
147
|
+
header_cols, line_cols = _read_template_cols(template)
|
|
148
|
+
|
|
149
|
+
# 拉一条真实发票样本
|
|
150
|
+
console.print("[cyan]正在获取样本数据...[/cyan]")
|
|
151
|
+
client = get_client()
|
|
152
|
+
status, body = client.get("/v2/invoice-requests", params={"pageSize": 20})
|
|
153
|
+
requests = (body.get("data") or {}).get("invoiceRequests", [])
|
|
154
|
+
sample_ir = next(
|
|
155
|
+
(r for r in requests
|
|
156
|
+
if r.get("invoiceRequestStatus") == "INVOICED_SUCCESS" and r.get("invoices")),
|
|
157
|
+
None,
|
|
158
|
+
)
|
|
159
|
+
if not sample_ir:
|
|
160
|
+
console.print("[red]未找到已开票成功的申请单,无法采集样本。[/red]")
|
|
161
|
+
raise typer.Exit(1)
|
|
162
|
+
|
|
163
|
+
inv_id = sample_ir["invoices"][0]["invoiceId"]
|
|
164
|
+
_, inv_body = client.get(f"/v2/invoices/{inv_id}")
|
|
165
|
+
invoice_json = inv_body.get("data") or inv_body
|
|
166
|
+
|
|
167
|
+
# 下载 KDUBL XML
|
|
168
|
+
console.print("[cyan]正在下载 KDUBL XML 样本...[/cyan]")
|
|
169
|
+
token = client.headers.get("Authorization", "").removeprefix("Bearer ")
|
|
170
|
+
xml_bytes = _fetch_source_xml(inv_id, token, company)
|
|
171
|
+
xml_fields_list = flatten_xml_fields(xml_bytes) if xml_bytes else []
|
|
172
|
+
|
|
173
|
+
# 构建输出数据
|
|
174
|
+
result = {
|
|
175
|
+
"template_path": template,
|
|
176
|
+
"header_columns": [{"en": en, "zh": zh} for en, zh in header_cols],
|
|
177
|
+
"line_columns": [{"en": en, "zh": zh} for en, zh in line_cols],
|
|
178
|
+
"sample_invoice_json": invoice_json,
|
|
179
|
+
"sample_xml_fields": xml_fields_list[:80], # 限制长度
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
out_str = json.dumps(result, ensure_ascii=False, indent=2)
|
|
183
|
+
|
|
184
|
+
if output:
|
|
185
|
+
Path(output).write_text(out_str)
|
|
186
|
+
console.print(f"[green]✓ 样本数据已保存到:{output}[/green]")
|
|
187
|
+
console.print("请将文件内容提供给 Claude,让其推断字段映射。")
|
|
188
|
+
else:
|
|
189
|
+
console.print("\n[bold yellow]── 样本数据(请复制给 Claude 推断映射)──[/bold yellow]\n")
|
|
190
|
+
console.print(out_str)
|
|
191
|
+
console.print(f"\n[dim]模版路径:{template}[/dim]")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ── 命令:save-mapping(保存 Claude 推断的映射表)──────────────────────────────
|
|
195
|
+
|
|
196
|
+
@app.command("save-mapping")
|
|
197
|
+
def export_save_mapping(
|
|
198
|
+
template: str = typer.Option(..., "--template", "-t", help="Excel 模版路径"),
|
|
199
|
+
mapping_file: Optional[str] = typer.Option(None, "--file", "-f",
|
|
200
|
+
help="包含映射 JSON 的文件路径"),
|
|
201
|
+
mapping_json: Optional[str] = typer.Option(None, "--json", "-j",
|
|
202
|
+
help="直接传入映射 JSON 字符串"),
|
|
203
|
+
):
|
|
204
|
+
"""保存字段映射表到本地缓存(~/.elc/mappings/)。
|
|
205
|
+
|
|
206
|
+
映射 JSON 格式:
|
|
207
|
+
{
|
|
208
|
+
"header_mapping": {"*invoiceNo": {"source": "json", "path": "invoiceNo"}, ...},
|
|
209
|
+
"line_mapping": {"*itemName": {"source": "json", "path": "itemName"}, ...}
|
|
210
|
+
}
|
|
211
|
+
"""
|
|
212
|
+
template = str(Path(template).expanduser().resolve())
|
|
213
|
+
if not Path(template).exists():
|
|
214
|
+
console.print(f"[red]模版文件不存在:{template}[/red]")
|
|
215
|
+
raise typer.Exit(1)
|
|
216
|
+
|
|
217
|
+
if mapping_file:
|
|
218
|
+
mapping = json.loads(Path(mapping_file).expanduser().read_text())
|
|
219
|
+
elif mapping_json:
|
|
220
|
+
mapping = json.loads(mapping_json)
|
|
221
|
+
else:
|
|
222
|
+
console.print("[red]需要 --file 或 --json 参数[/red]")
|
|
223
|
+
raise typer.Exit(1)
|
|
224
|
+
|
|
225
|
+
if "header_mapping" not in mapping or "line_mapping" not in mapping:
|
|
226
|
+
console.print("[red]映射 JSON 格式错误,需包含 header_mapping 和 line_mapping[/red]")
|
|
227
|
+
raise typer.Exit(1)
|
|
228
|
+
|
|
229
|
+
save_mapping(template, mapping)
|
|
230
|
+
|
|
231
|
+
# 展示摘要
|
|
232
|
+
hm = mapping["header_mapping"]
|
|
233
|
+
lm = mapping["line_mapping"]
|
|
234
|
+
json_count = sum(1 for r in {**hm, **lm}.values() if r.get("source") == "json")
|
|
235
|
+
xml_count = sum(1 for r in {**hm, **lm}.values() if r.get("source") == "xml")
|
|
236
|
+
none_count = sum(1 for r in {**hm, **lm}.values() if r.get("source") == "none")
|
|
237
|
+
|
|
238
|
+
console.print(f"[green]✓ 映射表已保存[/green](JSON字段:{json_count},XML字段:{xml_count},无映射:{none_count})")
|
|
239
|
+
console.print(f" 缓存位置:~/.elc/mappings/")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# ── 命令:invoice(导出发票到 Excel)─────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
@app.command("invoice")
|
|
245
|
+
def export_invoice(
|
|
246
|
+
template: str = typer.Option(..., "--template", "-t", help="Excel 模版路径"),
|
|
247
|
+
month: Optional[str] = typer.Option(None, "--month", "-m",
|
|
248
|
+
help="月份,如 2026-06,默认当月"),
|
|
249
|
+
company: Optional[str] = typer.Option(None, "--company", "-c", help="公司 ID"),
|
|
250
|
+
out: str = typer.Option(".", "--out", "-o", help="输出目录"),
|
|
251
|
+
max_batches: int = typer.Option(500, "--max-batches", help="最大查询批次(每批20条),防止数据量过大"),
|
|
252
|
+
):
|
|
253
|
+
"""导出发票数据到 Excel。
|
|
254
|
+
|
|
255
|
+
需先运行 elc export init 并通过 elc export save-mapping 保存映射。
|
|
256
|
+
"""
|
|
257
|
+
template = str(Path(template).expanduser().resolve())
|
|
258
|
+
if not Path(template).exists():
|
|
259
|
+
console.print(f"[red]模版文件不存在:{template}[/red]")
|
|
260
|
+
raise typer.Exit(1)
|
|
261
|
+
|
|
262
|
+
if not mapping_exists(template):
|
|
263
|
+
console.print("[red]映射表未初始化,请先运行:[/red]")
|
|
264
|
+
console.print(f" elc export init --template {template}")
|
|
265
|
+
console.print(" (然后将输出给 Claude,让其生成映射,再运行 elc export save-mapping)")
|
|
266
|
+
raise typer.Exit(1)
|
|
267
|
+
|
|
268
|
+
mapping = load_mapping(template)
|
|
269
|
+
company = company or os.environ.get("X_COMPANY_ID", "")
|
|
270
|
+
month = month or date.today().strftime("%Y-%m")
|
|
271
|
+
|
|
272
|
+
console.print(f"[cyan]导出月份:{month},公司:{company}[/cyan]")
|
|
273
|
+
|
|
274
|
+
# ① 收集 invoiceId 列表
|
|
275
|
+
inv_metas = _collect_invoices(month, max_batches=max_batches)
|
|
276
|
+
if not inv_metas:
|
|
277
|
+
console.print("[yellow]未找到符合条件的发票,退出。[/yellow]")
|
|
278
|
+
raise typer.Exit(0)
|
|
279
|
+
console.print(f"共找到 [bold]{len(inv_metas)}[/bold] 张发票")
|
|
280
|
+
|
|
281
|
+
# ② 判断是否需要下载 XML
|
|
282
|
+
xml_xpath_map = {
|
|
283
|
+
col: rule["xpath"]
|
|
284
|
+
for col, rule in {**mapping.get("header_mapping", {}), **mapping.get("line_mapping", {})}.items()
|
|
285
|
+
if rule.get("source") == "xml" and rule.get("xpath")
|
|
286
|
+
}
|
|
287
|
+
need_xml = bool(xml_xpath_map)
|
|
288
|
+
|
|
289
|
+
client = get_client()
|
|
290
|
+
token = client.headers.get("Authorization", "").removeprefix("Bearer ")
|
|
291
|
+
|
|
292
|
+
invoices_out: list[dict] = []
|
|
293
|
+
|
|
294
|
+
with Progress(
|
|
295
|
+
SpinnerColumn(),
|
|
296
|
+
TextColumn("[progress.description]{task.description}"),
|
|
297
|
+
BarColumn(),
|
|
298
|
+
TaskProgressColumn(),
|
|
299
|
+
console=console,
|
|
300
|
+
) as progress:
|
|
301
|
+
task = progress.add_task("处理发票...", total=len(inv_metas))
|
|
302
|
+
|
|
303
|
+
for meta in inv_metas:
|
|
304
|
+
inv_id = meta["invoiceId"]
|
|
305
|
+
progress.update(task, description=f"处理 {inv_id[:16]}...")
|
|
306
|
+
|
|
307
|
+
# 拉发票详情 JSON
|
|
308
|
+
_, inv_body = client.get(f"/v2/invoices/{inv_id}")
|
|
309
|
+
invoice_json = inv_body.get("data") or inv_body
|
|
310
|
+
invoice_json["sourceSystem"] = meta.get("sourceSystem", "")
|
|
311
|
+
|
|
312
|
+
# 下载 XML(按需)
|
|
313
|
+
xml_fields: dict = {}
|
|
314
|
+
if need_xml:
|
|
315
|
+
xml_bytes = _fetch_source_xml(inv_id, token, company)
|
|
316
|
+
if xml_bytes:
|
|
317
|
+
xml_fields = extract_fields(xml_bytes, xml_xpath_map)
|
|
318
|
+
|
|
319
|
+
row = apply_mapping(invoice_json, xml_fields, mapping)
|
|
320
|
+
invoices_out.append(row)
|
|
321
|
+
progress.advance(task)
|
|
322
|
+
|
|
323
|
+
# ③ 写 Excel
|
|
324
|
+
ym = month.replace("-", "")
|
|
325
|
+
out_path = str(Path(out).expanduser() / f"invoice_export_{ym}.xlsx")
|
|
326
|
+
count = write_excel(invoices_out, template, out_path, mapping)
|
|
327
|
+
|
|
328
|
+
console.print(f"\n[green]✓ 导出完成:{out_path}[/green]")
|
|
329
|
+
console.print(f" 共导出 [bold]{count}[/bold] 张发票")
|