fima-cli 0.0.0-stage → 1.3.0
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.
- package/README.md +59 -2
- package/bin/fima.cjs +35 -0
- package/fima.py +141 -0
- package/package.json +26 -4
package/README.md
CHANGED
|
@@ -1,3 +1,60 @@
|
|
|
1
|
-
#
|
|
1
|
+
# fima-cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Fima 财务报销系统的命令行客户端,需要配合 `fima-web` 服务使用。
|
|
4
|
+
|
|
5
|
+
## npm 安装
|
|
6
|
+
|
|
7
|
+
运行环境:Node.js 22 及以上、Python 3.8 及以上。Python 只使用标准库,无需额外安装 pip 依赖。macOS / Linux 需确保 `python3` 在 PATH 中;Windows 支持 `py -3`、`python3` 或 `python`。
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g fima-cli
|
|
11
|
+
fima --version
|
|
12
|
+
fima --help
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
也可以使用 `pnpm add -g fima-cli` 安装,或直接运行 `npx fima-cli --help`。
|
|
16
|
+
|
|
17
|
+
启动 `fima-web` 后,执行 `fima login` 登录,再使用业务指令。
|
|
18
|
+
|
|
19
|
+
## 从源码运行
|
|
20
|
+
|
|
21
|
+
先启动 `fima-web`,然后直接使用:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
./fima -v
|
|
25
|
+
./fima -h
|
|
26
|
+
./fima login
|
|
27
|
+
./fima whoami
|
|
28
|
+
./fima create --json '{"title":"上海客户拜访","category":"差旅费","amount":1280,"date":"2026-07-12","invoice":"./invoice.pdf","submit":true}'
|
|
29
|
+
./fima list
|
|
30
|
+
./fima show 1
|
|
31
|
+
./fima manager-approve --json '{"id":1,"operator":"李主管","comment":"同意"}'
|
|
32
|
+
./fima finance-approve --json '{"id":1,"operator":"陈会计","comment":"票据无误"}'
|
|
33
|
+
./fima pay --json '{"id":1,"operator":"陈出纳","comment":"已模拟打款"}'
|
|
34
|
+
./fima logout
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
从源码安装为全局命令(macOS / Linux):
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
./install.sh
|
|
41
|
+
fima -h
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
服务默认地址为 `http://127.0.0.1:8000`,可设置 `FIMA_URL`,或在子命令前传入 `--url`,例如 `fima --url https://your-fima.example.com login`。请使用服务管理员提供的账号登录。执行 `login` 会拉起浏览器中的专用登录框,账号密码只在网页中填写;CLI 会自动检测登录完成并把会话 Cookie 保存在 `~/.fima/session.json`(可用 `FIMA_COOKIE_FILE` 覆盖)。网页会明确提示“CLI 登录已完成”,此时直接关闭当前页面即可。可通过 `--timeout` 调整等待时长(默认 5 分钟)。后续业务请求会自动携带 Cookie;可通过 `whoami` 查看当前登录账号,`logout` 会退出登录并清除本地会话 Cookie。
|
|
45
|
+
|
|
46
|
+
## 参数传递
|
|
47
|
+
|
|
48
|
+
字段超过两个的命令统一通过 `--json` 传入一个 JSON 对象,避免 Agent 在长参数列表中遗漏或混淆字段。先执行 `fima <command> -h` 查看该命令的 JSON 结构。
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# 创建报销单:title、amount、date 必填
|
|
52
|
+
fima create --json '{"title":"上海客户拜访","amount":1280,"date":"2026-07-12","category":"差旅费","description":"客户现场拜访","invoice":"./invoice.pdf","submit":true}'
|
|
53
|
+
|
|
54
|
+
# 流程操作:id 必填;reject 还必须提供 comment
|
|
55
|
+
fima submit --json '{"id":1}'
|
|
56
|
+
fima manager-approve --json '{"id":1,"operator":"李主管","comment":"同意"}'
|
|
57
|
+
fima reject --json '{"id":1,"operator":"李主管","comment":"缺少行程单"}'
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`list`、`show` 和 `upload` 仍使用简洁的普通参数:`fima list --status submitted`、`fima show 1`、`fima upload 1 ./invoice.pdf`。
|
package/bin/fima.cjs
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { spawnSync } = require('node:child_process');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
7
|
+
const candidates = process.platform === 'win32'
|
|
8
|
+
? [['py', '-3'], ['python3'], ['python']]
|
|
9
|
+
: [['python3']];
|
|
10
|
+
const python = candidates.find(([command, ...args]) => {
|
|
11
|
+
const result = spawnSync(command, [
|
|
12
|
+
...args, '-c', 'import sys; sys.exit(0 if sys.version_info >= (3, 8) else 1)',
|
|
13
|
+
], { stdio: 'ignore' });
|
|
14
|
+
return !result.error && result.status === 0;
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
if (!python) {
|
|
18
|
+
console.error('错误:Fima CLI 需要 Python 3.8 或更高版本,请安装 Python 并将其加入 PATH。');
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const [command, ...args] = python;
|
|
23
|
+
const result = spawnSync(command, [
|
|
24
|
+
...args, path.join(__dirname, '..', 'fima.py'), ...process.argv.slice(2),
|
|
25
|
+
], { stdio: 'inherit' });
|
|
26
|
+
|
|
27
|
+
if (result.error) {
|
|
28
|
+
console.error(`错误:无法启动 Fima CLI:${result.error.message}`);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
if (result.signal) {
|
|
32
|
+
process.kill(process.pid, result.signal);
|
|
33
|
+
} else {
|
|
34
|
+
process.exit(result.status ?? 1);
|
|
35
|
+
}
|
package/fima.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Fima CLI - 通过命令行操作 Fima 财务报销系统。"""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import argparse, json, math, mimetypes, os, sys, time, webbrowser
|
|
5
|
+
from http.cookies import SimpleCookie
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from urllib.parse import quote
|
|
8
|
+
from urllib.error import HTTPError, URLError
|
|
9
|
+
from urllib.request import Request, urlopen
|
|
10
|
+
|
|
11
|
+
VERSION="1.3.0"; DEFAULT_URL=os.environ.get("FIMA_URL","http://127.0.0.1:8000")
|
|
12
|
+
|
|
13
|
+
def cookie_file(): return Path(os.environ.get("FIMA_COOKIE_FILE", Path.home() / ".fima" / "session.json"))
|
|
14
|
+
def load_cookie():
|
|
15
|
+
try: return json.loads(cookie_file().read_text(encoding="utf-8"))["cookie"]
|
|
16
|
+
except (OSError, KeyError, json.JSONDecodeError): return None
|
|
17
|
+
def save_cookie(value):
|
|
18
|
+
target=cookie_file(); target.parent.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
target.write_text(json.dumps({"cookie":value}),encoding="utf-8"); target.chmod(0o600)
|
|
20
|
+
def clear_cookie():
|
|
21
|
+
try: cookie_file().unlink()
|
|
22
|
+
except FileNotFoundError: pass
|
|
23
|
+
|
|
24
|
+
def request(method,path,data=None,file=None,include_headers=False):
|
|
25
|
+
url=ARGS.url.rstrip("/")+path; headers={}; body=None
|
|
26
|
+
if cookie:=load_cookie(): headers["Cookie"]=cookie
|
|
27
|
+
if file:
|
|
28
|
+
p=Path(file)
|
|
29
|
+
if not p.is_file(): fail(f"文件不存在:{p}")
|
|
30
|
+
boundary="----FimaBoundary7MA4YWxk"; raw=p.read_bytes(); ctype=mimetypes.guess_type(p.name)[0] or "application/octet-stream"
|
|
31
|
+
body=(f"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{p.name}\"\r\nContent-Type: {ctype}\r\n\r\n".encode()+raw+f"\r\n--{boundary}--\r\n".encode())
|
|
32
|
+
headers["Content-Type"]=f"multipart/form-data; boundary={boundary}"
|
|
33
|
+
elif data is not None:
|
|
34
|
+
body=json.dumps(data,ensure_ascii=False).encode();headers["Content-Type"]="application/json"
|
|
35
|
+
try:
|
|
36
|
+
with urlopen(Request(url,data=body,headers=headers,method=method),timeout=10) as r:
|
|
37
|
+
result=json.loads(r.read())
|
|
38
|
+
return (result,r.headers) if include_headers else result
|
|
39
|
+
except HTTPError as e:
|
|
40
|
+
try: msg=json.loads(e.read()).get("error",str(e))
|
|
41
|
+
except Exception: msg=str(e)
|
|
42
|
+
fail(msg)
|
|
43
|
+
except URLError: fail(f"无法连接 Fima Web:{url}\n请先运行:cd fima-web && python3 app.py")
|
|
44
|
+
|
|
45
|
+
def fail(msg): print(f"错误:{msg}",file=sys.stderr);raise SystemExit(1)
|
|
46
|
+
def json_object(value):
|
|
47
|
+
try: data=json.loads(value)
|
|
48
|
+
except json.JSONDecodeError as e: raise argparse.ArgumentTypeError(f"不是合法 JSON:{e.msg}")
|
|
49
|
+
if not isinstance(data,dict): raise argparse.ArgumentTypeError("JSON 必须是对象,例如:'{\"id\": 1}'")
|
|
50
|
+
return data
|
|
51
|
+
def required(payload,key):
|
|
52
|
+
value=payload.get(key)
|
|
53
|
+
if value is None or value=="": fail(f"JSON 缺少必填字段:{key}")
|
|
54
|
+
return value
|
|
55
|
+
def optional_string(payload,key,default=""):
|
|
56
|
+
value=payload.get(key,default)
|
|
57
|
+
if not isinstance(value,str): fail(f"JSON 字段 {key} 必须是字符串")
|
|
58
|
+
return value
|
|
59
|
+
def positive_number(payload,key):
|
|
60
|
+
value=required(payload,key)
|
|
61
|
+
if isinstance(value,bool) or not isinstance(value,(int,float)) or not math.isfinite(value) or value<=0: fail(f"JSON 字段 {key} 必须是大于 0 的数字")
|
|
62
|
+
return value
|
|
63
|
+
def allow_fields(payload,*fields):
|
|
64
|
+
unknown=set(payload)-set(fields)
|
|
65
|
+
if unknown: fail(f"JSON 包含不支持的字段:{', '.join(sorted(unknown))}")
|
|
66
|
+
def fmt_money(x): return f"¥{float(x):,.2f}"
|
|
67
|
+
def print_item(x,detail=False):
|
|
68
|
+
invoice=x.get("invoice_name") or "未上传"
|
|
69
|
+
print(f"{x['id']:>3} {x['expense_no']:<20} {x['status_label']:<10} {fmt_money(x['amount']):>12} {x['title']} [{invoice}]")
|
|
70
|
+
if detail:
|
|
71
|
+
print(f" 申请人:{x['applicant']} / {x['department']} 日期:{x['expense_date']} 类别:{x['category']}")
|
|
72
|
+
print(f" 说明:{x.get('description') or '-'}")
|
|
73
|
+
for l in reversed(x.get("logs",[])): print(f" · {l['created_at'].replace('T',' ')} {l['operator']}:{l['comment']}")
|
|
74
|
+
|
|
75
|
+
def create(a):
|
|
76
|
+
payload=a.payload
|
|
77
|
+
allow_fields(payload,"title","amount","date","category","description","applicant","department","invoice","submit")
|
|
78
|
+
title=required(payload,"title"); date=required(payload,"date")
|
|
79
|
+
if not isinstance(title,str) or not isinstance(date,str): fail("JSON 字段 title 和 date 必须是字符串")
|
|
80
|
+
invoice=payload.get("invoice")
|
|
81
|
+
if invoice is not None and not isinstance(invoice,str): fail("JSON 字段 invoice 必须是文件路径字符串")
|
|
82
|
+
submit=payload.get("submit",False)
|
|
83
|
+
if not isinstance(submit,bool): fail("JSON 字段 submit 必须是 true 或 false")
|
|
84
|
+
data={"title":title,"category":optional_string(payload,"category","其他"),"amount":positive_number(payload,"amount"),"expense_date":date,"description":optional_string(payload,"description"),"applicant":optional_string(payload,"applicant","王小明"),"department":optional_string(payload,"department","产品研发部")}
|
|
85
|
+
x=request("POST","/api/expenses",data); print(f"已创建:{x['expense_no']}(ID: {x['id']})")
|
|
86
|
+
if invoice is not None:
|
|
87
|
+
request("POST",f"/api/expenses/{x['id']}/invoice",file=invoice);print(f"已上传发票:{Path(invoice).name}")
|
|
88
|
+
if submit: request("POST",f"/api/expenses/{x['id']}/submit",{});print("已提交审核")
|
|
89
|
+
def list_items(a):
|
|
90
|
+
rows=request("GET","/api/expenses"+(f"?status={a.status}" if a.status else ""));print(" ID 报销单号 状态 金额 事项 [发票]")
|
|
91
|
+
print("-"*90)
|
|
92
|
+
for x in rows: print_item(x)
|
|
93
|
+
print(f"\n共 {len(rows)} 条")
|
|
94
|
+
def show(a): print_item(request("GET",f"/api/expenses/{a.id}"),True)
|
|
95
|
+
def upload(a): x=request("POST",f"/api/expenses/{a.id}/invoice",file=a.file);print(f"发票已上传:{x['invoice_name']}")
|
|
96
|
+
def action(a):
|
|
97
|
+
names={"submit":"提交审核","manager-approve":"主管审批通过","finance-approve":"财务审批通过","reject":"驳回","pay":"确认模拟打款"}
|
|
98
|
+
payload=a.payload; expense_id=required(payload,"id")
|
|
99
|
+
allow_fields(payload,"id","operator","comment")
|
|
100
|
+
if isinstance(expense_id,bool) or not isinstance(expense_id,int) or expense_id<=0: fail("JSON 字段 id 必须是正整数")
|
|
101
|
+
operator=optional_string(payload,"operator",a.default_operator)
|
|
102
|
+
comment=optional_string(payload,"comment",a.default_comment)
|
|
103
|
+
if a.command=="reject" and "comment" not in payload: fail("JSON 缺少必填字段:comment")
|
|
104
|
+
x=request("POST",f"/api/expenses/{expense_id}/{a.command}",{"operator":operator,"comment":comment});print(f"操作成功:{names[a.command]},当前状态:{x['status_label']}")
|
|
105
|
+
def login(a):
|
|
106
|
+
if a.timeout <= 0: fail("--timeout 必须大于 0")
|
|
107
|
+
session=request("POST","/api/cli-login",{})
|
|
108
|
+
browser_url=(ARGS.url.rstrip("/")+"/?cli_login="+quote(session["id"],safe="")+"&cli_token="+quote(session["browser_token"],safe=""))
|
|
109
|
+
print("正在打开浏览器,请在网页中完成登录。登录成功后可直接关闭该页面。")
|
|
110
|
+
if not webbrowser.open(browser_url, new=2): print(f"浏览器未自动打开,请访问:{browser_url}")
|
|
111
|
+
print("等待浏览器登录完成…", flush=True)
|
|
112
|
+
deadline=time.monotonic()+a.timeout
|
|
113
|
+
while time.monotonic()<deadline:
|
|
114
|
+
result=request("GET",f"/api/cli-login/{quote(session['id'],safe='')}?token={quote(session['poll_token'],safe='')}")
|
|
115
|
+
if result["status"]=="complete":
|
|
116
|
+
save_cookie(result["cookie"])
|
|
117
|
+
print(f"登录成功:{result['username']}(浏览器页面现在可以关闭)")
|
|
118
|
+
return
|
|
119
|
+
time.sleep(1)
|
|
120
|
+
fail("等待网页登录超时,请重新执行 fima login")
|
|
121
|
+
def logout(a):
|
|
122
|
+
request("POST","/api/logout",{});clear_cookie();print("已退出登录")
|
|
123
|
+
def whoami(a):
|
|
124
|
+
user=request("GET","/api/me");print(f"当前登录用户:{user.get('display_name') or user['username']} (@{user['username']})")
|
|
125
|
+
|
|
126
|
+
parser=argparse.ArgumentParser(prog="fima",description="Fima 财务报销命令行工具",epilog="多字段命令使用 JSON:fima create --json '{\"title\": \"客户拜访\", \"amount\": 280, \"date\": \"2026-07-12\"}'")
|
|
127
|
+
parser.add_argument("-v","--version",action="version",version=f"fima {VERSION}")
|
|
128
|
+
parser.add_argument("--url",default=DEFAULT_URL,help=f"Fima Web 地址(默认:{DEFAULT_URL})")
|
|
129
|
+
sub=parser.add_subparsers(dest="command",title="支持的指令",required=True)
|
|
130
|
+
p=sub.add_parser("login",help="打开浏览器登录并保存 Cookie");p.add_argument("--timeout",type=int,default=300,help="等待网页登录完成的秒数(默认:300)");p.set_defaults(func=login)
|
|
131
|
+
p=sub.add_parser("logout",help="退出登录并清除本地 Cookie");p.set_defaults(func=logout)
|
|
132
|
+
p=sub.add_parser("whoami",help="查看当前登录账号");p.set_defaults(func=whoami)
|
|
133
|
+
p=sub.add_parser("create",help="创建报销单(使用 JSON 传递字段)");p.add_argument("--json",dest="payload",required=True,type=json_object,metavar="JSON",help='JSON 对象;必填 title、amount、date;可选 category、description、applicant、department、invoice、submit');p.set_defaults(func=create)
|
|
134
|
+
p=sub.add_parser("list",help="查看报销单列表");p.add_argument("--status",choices=["draft","submitted","manager_approved","finance_approved","paid","rejected"]);p.set_defaults(func=list_items)
|
|
135
|
+
p=sub.add_parser("show",help="查看报销单详情和流转记录");p.add_argument("id",type=int);p.set_defaults(func=show)
|
|
136
|
+
p=sub.add_parser("upload",help="上传或替换发票");p.add_argument("id",type=int);p.add_argument("file");p.set_defaults(func=upload)
|
|
137
|
+
for cmd,help_text,default_operator in [("submit","提交报销单","王小明"),("manager-approve","主管审核通过","李主管"),("finance-approve","财务审核通过","陈会计"),("pay","确认模拟打款","陈出纳"),("reject","驳回报销单","李主管")]:
|
|
138
|
+
json_help='JSON 对象;必填 id;可选 operator、comment' if cmd!="reject" else 'JSON 对象;必填 id、comment;可选 operator'
|
|
139
|
+
p=sub.add_parser(cmd,help=f"{help_text}(使用 JSON 传递字段)");p.add_argument("--json",dest="payload",required=True,type=json_object,metavar="JSON",help=json_help);p.set_defaults(func=action,default_operator=default_operator,default_comment=help_text)
|
|
140
|
+
ARGS=parser.parse_args()
|
|
141
|
+
if __name__=="__main__": ARGS.func(ARGS)
|
package/package.json
CHANGED
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fima-cli",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.3.0",
|
|
4
|
+
"description": "Command-line client for the Fima expense reimbursement system",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/chujianyun/fima-cli.git"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"fima": "bin/fima.cjs"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"bin/fima.cjs",
|
|
15
|
+
"fima.py"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=22"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test": "node --test",
|
|
22
|
+
"prepublishOnly": "npm test"
|
|
23
|
+
},
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public",
|
|
26
|
+
"registry": "https://registry.npmjs.org/"
|
|
27
|
+
}
|
|
28
|
+
}
|