@lichidental/saas-cli 0.0.0-stage → 1.0.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/AGENTS.md +246 -0
- package/README.md +92 -2
- package/api/custom-mark.mjs +52 -0
- package/api/custom.mjs +16 -0
- package/api/employee.mjs +19 -0
- package/api/treat-item.mjs +10 -0
- package/cli.mjs +88 -0
- package/commands/auth/login.mjs +142 -0
- package/commands/auth/logout.mjs +39 -0
- package/commands/auth/status.mjs +64 -0
- package/commands/config/set-token.mjs +39 -0
- package/commands/config/show.mjs +39 -0
- package/commands/reservation/_shared.mjs +216 -0
- package/commands/reservation/create.mjs +146 -0
- package/commands/reservation/doctors.mjs +21 -0
- package/commands/reservation/items.mjs +41 -0
- package/commands/reservation/list.mjs +78 -0
- package/commands/reservation/patients.mjs +30 -0
- package/docs/CLI/344/275/277/347/224/250/346/211/213/345/206/214.md +198 -0
- package/docs/api/openapi.yaml +442 -0
- package/docs/api//346/216/245/345/217/243/350/257/264/346/230/216.md +259 -0
- package/docs//345/274/200/345/217/221/346/214/207/345/215/227.md +176 -0
- package/lib/config.mjs +109 -0
- package/lib/context.mjs +49 -0
- package/lib/errors.mjs +61 -0
- package/lib/http.mjs +106 -0
- package/lib/match.mjs +39 -0
- package/lib/options.mjs +32 -0
- package/lib/output.mjs +31 -0
- package/lib/time.mjs +41 -0
- package/lib/types.mjs +51 -0
- package/lib/update-check.mjs +112 -0
- package/package.json +40 -4
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// commands/auth/login.mjs — 登录:验证 token 并保存到本机
|
|
2
|
+
// 三种用法:
|
|
3
|
+
// 1) saas-cli auth login --token <token> 直接验证并保存
|
|
4
|
+
// 2) ... --token - (或管道) 从标准输入读取 token 后验证保存
|
|
5
|
+
// 3) saas-cli auth login(无 --token) 引导打开授权页,给出后续操作指引(不阻塞等待)
|
|
6
|
+
import { spawn } from "node:child_process"
|
|
7
|
+
import readline from "node:readline"
|
|
8
|
+
import { CliError, EXIT_CODES } from "../../lib/errors.mjs"
|
|
9
|
+
import { createOutput } from "../../lib/output.mjs"
|
|
10
|
+
import { buildContextWithToken } from "../../lib/context.mjs"
|
|
11
|
+
import { assertValueNotOptionLike } from "../../lib/options.mjs"
|
|
12
|
+
import { setToken, maskToken } from "../../lib/config.mjs"
|
|
13
|
+
import { ENV_BASE_URLS } from "../../lib/http.mjs"
|
|
14
|
+
import { listDoctors } from "../../api/employee.mjs"
|
|
15
|
+
|
|
16
|
+
/** 授权页地址:默认 <环境地址>/cli-auth,可用 --auth-url 覆盖 */
|
|
17
|
+
function authUrlFor(env, override) {
|
|
18
|
+
if (override) {
|
|
19
|
+
if (!/^https?:\/\//i.test(override)) {
|
|
20
|
+
throw new CliError(`--auth-url 必须是 http/https 地址(收到:${override})`, { exitCode: EXIT_CODES.USAGE })
|
|
21
|
+
}
|
|
22
|
+
return override
|
|
23
|
+
}
|
|
24
|
+
const base = ENV_BASE_URLS[env]
|
|
25
|
+
if (!base) {
|
|
26
|
+
throw new CliError(`未知环境「${env}」,可选:${Object.keys(ENV_BASE_URLS).join(" / ")}`, {
|
|
27
|
+
exitCode: EXIT_CODES.USAGE,
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
return `${base}/cli-auth`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 用系统默认浏览器打开 URL(失败静默,不影响主流程) */
|
|
34
|
+
function openBrowser(url) {
|
|
35
|
+
const [cmd, args] =
|
|
36
|
+
process.platform === "win32"
|
|
37
|
+
? ["cmd", ["/c", "start", "", `"${url}"`]]
|
|
38
|
+
: process.platform === "darwin"
|
|
39
|
+
? ["open", [url]]
|
|
40
|
+
: ["xdg-open", [url]]
|
|
41
|
+
try {
|
|
42
|
+
const p = spawn(cmd, args, { stdio: "ignore", detached: true })
|
|
43
|
+
p.on("error", () => {})
|
|
44
|
+
p.unref()
|
|
45
|
+
} catch {
|
|
46
|
+
// 打开失败不阻塞:使用者可手动复制链接
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 读取标准输入首行(管道场景);超时(10 秒)或无内容返回空串 */
|
|
51
|
+
function readStdinLine() {
|
|
52
|
+
return new Promise((resolve) => {
|
|
53
|
+
const rl = readline.createInterface({ input: process.stdin })
|
|
54
|
+
const timer = setTimeout(() => {
|
|
55
|
+
rl.close()
|
|
56
|
+
resolve("")
|
|
57
|
+
}, 10000)
|
|
58
|
+
rl.once("line", (line) => {
|
|
59
|
+
clearTimeout(timer)
|
|
60
|
+
rl.close()
|
|
61
|
+
resolve(line.trim())
|
|
62
|
+
})
|
|
63
|
+
rl.once("close", () => {
|
|
64
|
+
clearTimeout(timer)
|
|
65
|
+
resolve("")
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** @param {import("commander").Command} topic */
|
|
71
|
+
export function register(topic) {
|
|
72
|
+
topic
|
|
73
|
+
.command("login")
|
|
74
|
+
.description("登录:验证 token 并保存到本机;不带 --token 时引导打开授权页")
|
|
75
|
+
.option("--env <env>", "环境:test | pre | prod", "test")
|
|
76
|
+
.option("--token <token>", "要登录的 token;- 表示从标准输入读取;省略则引导打开授权页")
|
|
77
|
+
.option("--auth-url <url>", "授权页地址(默认 <环境地址>/cli-auth)")
|
|
78
|
+
.option("--no-open", "不自动打开浏览器,只打印授权页链接")
|
|
79
|
+
.option("--json", "输出结构化 JSON(默认输出人读摘要)")
|
|
80
|
+
.option("--verbose", "打印请求详情(token 脱敏)")
|
|
81
|
+
.action(async function () {
|
|
82
|
+
const opts = this.opts()
|
|
83
|
+
const out = createOutput({ json: Boolean(opts.json), verbose: Boolean(opts.verbose) })
|
|
84
|
+
let token = typeof opts.token === "string" ? opts.token.trim() : ""
|
|
85
|
+
|
|
86
|
+
if (token === "-") {
|
|
87
|
+
// 显式要求从标准输入读取(脚本/剪贴板管道场景)
|
|
88
|
+
token = await readStdinLine()
|
|
89
|
+
if (!token) {
|
|
90
|
+
throw new CliError("--token - 指定从标准输入读取,但没有读到内容", {
|
|
91
|
+
exitCode: EXIT_CODES.USAGE,
|
|
92
|
+
hint: "例:powershell -c Get-Clipboard | saas-cli auth login --token -",
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
} else if (token) {
|
|
96
|
+
assertValueNotOptionLike(opts.token, {
|
|
97
|
+
option: "--token",
|
|
98
|
+
hint: `用法:saas-cli auth login --env ${opts.env} --token <token>;或省略 --token 打开授权页按引导操作`,
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!token) {
|
|
103
|
+
// 无 token:引导模式(打开授权页 + 指引),不阻塞等待输入
|
|
104
|
+
const url = authUrlFor(opts.env, opts.authUrl)
|
|
105
|
+
const interactive = Boolean(process.stdin.isTTY) || Boolean(process.stdout.isTTY)
|
|
106
|
+
const opened = Boolean(opts.open) && interactive
|
|
107
|
+
if (opened) openBrowser(url)
|
|
108
|
+
out.success(
|
|
109
|
+
"auth login",
|
|
110
|
+
opts.env,
|
|
111
|
+
{ env: opts.env, authUrl: url, opened, configured: false },
|
|
112
|
+
[
|
|
113
|
+
opened ? "已在浏览器打开授权页(未打开则手动复制下面的链接):" : "请在浏览器中打开以下链接:",
|
|
114
|
+
` ${url}`,
|
|
115
|
+
`登录后点击「复制 token」,然后运行:`,
|
|
116
|
+
` saas-cli auth login --env ${opts.env} --token <粘贴的token>`,
|
|
117
|
+
"",
|
|
118
|
+
`也可以直接用剪贴板管道一步完成(PowerShell):`,
|
|
119
|
+
` powershell -c Get-Clipboard | saas-cli auth login --env ${opts.env} --token -`,
|
|
120
|
+
].join("\n")
|
|
121
|
+
)
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 验证 token:调只读接口(医生列表)确认有效性,通过后再保存
|
|
126
|
+
const { env, http } = buildContextWithToken(this, token)
|
|
127
|
+
const res = await listDoctors(http)
|
|
128
|
+
const doctorCount = (res.data ?? []).length
|
|
129
|
+
const file = setToken(env, token)
|
|
130
|
+
|
|
131
|
+
out.success(
|
|
132
|
+
"auth login",
|
|
133
|
+
env,
|
|
134
|
+
{ env, token: maskToken(token), verified: true, doctorCount, configFile: file },
|
|
135
|
+
[
|
|
136
|
+
`✓ 登录成功(环境:${env})`,
|
|
137
|
+
` token 已验证:${maskToken(token)},可访问 ${doctorCount} 名在职员工`,
|
|
138
|
+
` 已保存到:${file}`,
|
|
139
|
+
].join("\n")
|
|
140
|
+
)
|
|
141
|
+
})
|
|
142
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// commands/auth/logout.mjs — 退出登录:删除本机保存的 token
|
|
2
|
+
import { CliError, EXIT_CODES } from "../../lib/errors.mjs"
|
|
3
|
+
import { createOutput } from "../../lib/output.mjs"
|
|
4
|
+
import { removeToken, getConfigPath } from "../../lib/config.mjs"
|
|
5
|
+
import { ENV_BASE_URLS } from "../../lib/http.mjs"
|
|
6
|
+
|
|
7
|
+
/** @param {import("commander").Command} topic */
|
|
8
|
+
export function register(topic) {
|
|
9
|
+
topic
|
|
10
|
+
.command("logout")
|
|
11
|
+
.description("退出登录:删除本机保存的该环境 token")
|
|
12
|
+
.option("--env <env>", "环境:test | pre | prod", "test")
|
|
13
|
+
.option("--json", "输出结构化 JSON(默认输出人读摘要)")
|
|
14
|
+
.action(async function () {
|
|
15
|
+
const opts = this.opts()
|
|
16
|
+
const out = createOutput({ json: Boolean(opts.json) })
|
|
17
|
+
if (!ENV_BASE_URLS[opts.env]) {
|
|
18
|
+
throw new CliError(`未知环境「${opts.env}」,可选:${Object.keys(ENV_BASE_URLS).join(" / ")}`, {
|
|
19
|
+
exitCode: EXIT_CODES.USAGE,
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
const file = removeToken(opts.env)
|
|
23
|
+
if (file) {
|
|
24
|
+
out.success(
|
|
25
|
+
"auth logout",
|
|
26
|
+
opts.env,
|
|
27
|
+
{ env: opts.env, removed: true, configFile: file },
|
|
28
|
+
[`✓ 已退出登录(环境:${opts.env})`, ` 已删除本机保存的该环境 token`].join("\n")
|
|
29
|
+
)
|
|
30
|
+
} else {
|
|
31
|
+
out.success(
|
|
32
|
+
"auth logout",
|
|
33
|
+
opts.env,
|
|
34
|
+
{ env: opts.env, removed: false, configFile: getConfigPath() },
|
|
35
|
+
`环境 ${opts.env} 在本机没有保存 token(无需退出)`
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// commands/auth/status.mjs — 查看登录状态:token 来源/打码值/更新时间;--verify 联网验证
|
|
2
|
+
import { createOutput } from "../../lib/output.mjs"
|
|
3
|
+
import { buildContextWithToken } from "../../lib/context.mjs"
|
|
4
|
+
import { resolveToken, readConfig, maskToken, getConfigPath } from "../../lib/config.mjs"
|
|
5
|
+
import { listDoctors } from "../../api/employee.mjs"
|
|
6
|
+
|
|
7
|
+
/** @param {import("commander").Command} topic */
|
|
8
|
+
export function register(topic) {
|
|
9
|
+
topic
|
|
10
|
+
.command("status")
|
|
11
|
+
.description("查看登录状态:token 来源、打码值、更新时间;--verify 联网验证有效性")
|
|
12
|
+
.option("--env <env>", "环境:test | pre | prod", "test")
|
|
13
|
+
.option("--verify", "联网验证 token 是否有效(默认只显示本地状态,不联网)")
|
|
14
|
+
.option("--json", "输出结构化 JSON(默认输出人读摘要)")
|
|
15
|
+
.option("--verbose", "打印请求详情(token 脱敏)")
|
|
16
|
+
.action(async function () {
|
|
17
|
+
const opts = this.opts()
|
|
18
|
+
const out = createOutput({ json: Boolean(opts.json), verbose: Boolean(opts.verbose) })
|
|
19
|
+
const env = opts.env
|
|
20
|
+
const { token, source } = resolveToken({ env })
|
|
21
|
+
const configFile = getConfigPath()
|
|
22
|
+
|
|
23
|
+
if (!token) {
|
|
24
|
+
out.success(
|
|
25
|
+
"auth status",
|
|
26
|
+
env,
|
|
27
|
+
{ env, configured: false, configFile },
|
|
28
|
+
[
|
|
29
|
+
`环境 ${env}:未登录(本机没有可用 token)`,
|
|
30
|
+
` 登录方式:saas-cli auth login --env ${env}`,
|
|
31
|
+
` (也可用环境变量 SAAS_TOKEN 或命令后加 --token 临时传入)`,
|
|
32
|
+
].join("\n")
|
|
33
|
+
)
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const fromConfig = Boolean(source?.startsWith("配置文件"))
|
|
38
|
+
const updatedAt = fromConfig ? (readConfig()?.profiles?.[env]?.updatedAt ?? null) : null
|
|
39
|
+
|
|
40
|
+
let verified = null
|
|
41
|
+
let doctorCount = null
|
|
42
|
+
const lines = [
|
|
43
|
+
`环境 ${env}:已配置`,
|
|
44
|
+
` token:${maskToken(token)}(来源:${source}${updatedAt ? `,更新于 ${updatedAt}` : ""})`,
|
|
45
|
+
]
|
|
46
|
+
if (opts.verify) {
|
|
47
|
+
const { http } = buildContextWithToken(this, token)
|
|
48
|
+
const res = await listDoctors(http)
|
|
49
|
+
doctorCount = (res.data ?? []).length
|
|
50
|
+
verified = true
|
|
51
|
+
lines.push(` 验证:✓ 有效(可访问 ${doctorCount} 名在职员工)`)
|
|
52
|
+
} else {
|
|
53
|
+
lines.push(" (加 --verify 可联网验证 token 是否有效)")
|
|
54
|
+
}
|
|
55
|
+
lines.push(` 配置文件:${configFile}`)
|
|
56
|
+
|
|
57
|
+
out.success(
|
|
58
|
+
"auth status",
|
|
59
|
+
env,
|
|
60
|
+
{ env, configured: true, token: maskToken(token), source, updatedAt, verified, doctorCount, configFile },
|
|
61
|
+
lines.join("\n")
|
|
62
|
+
)
|
|
63
|
+
})
|
|
64
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// commands/config/set-token.mjs — 保存 token 到本地配置文件
|
|
2
|
+
import { CliError, EXIT_CODES } from "../../lib/errors.mjs"
|
|
3
|
+
import { createOutput } from "../../lib/output.mjs"
|
|
4
|
+
import { setToken, maskToken } from "../../lib/config.mjs"
|
|
5
|
+
import { ENV_BASE_URLS } from "../../lib/http.mjs"
|
|
6
|
+
|
|
7
|
+
/** @param {import("commander").Command} topic */
|
|
8
|
+
export function register(topic) {
|
|
9
|
+
topic
|
|
10
|
+
.command("set-token")
|
|
11
|
+
.description("保存某环境的 token 到本地配置文件(只需配置一次)")
|
|
12
|
+
.option("--env <env>", "环境:test | pre | prod", "test")
|
|
13
|
+
.requiredOption("--token <token>", "要保存的 token")
|
|
14
|
+
.option("--json", "输出结构化 JSON(默认输出人读摘要)")
|
|
15
|
+
.action(async function () {
|
|
16
|
+
const opts = this.opts()
|
|
17
|
+
const out = createOutput({ json: Boolean(opts.json) })
|
|
18
|
+
if (!ENV_BASE_URLS[opts.env]) {
|
|
19
|
+
throw new CliError(`未知环境「${opts.env}」,可选:${Object.keys(ENV_BASE_URLS).join(" / ")}`, {
|
|
20
|
+
exitCode: EXIT_CODES.USAGE,
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
const token = String(opts.token ?? "").trim()
|
|
24
|
+
// commander 会把紧跟 --token 的下一个选项当作它的值(如 "--token --json" 时 token="--json"),这里拦下,避免错误值被静默写进配置
|
|
25
|
+
if (!token || token.startsWith("-")) {
|
|
26
|
+
throw new CliError(!token ? "--token 不能为空" : `--token 的值看起来是另一个选项(「${token}」),不是 token`, {
|
|
27
|
+
exitCode: EXIT_CODES.USAGE,
|
|
28
|
+
hint: "用法:saas-cli config set-token --env <环境> --token <token>;--token 后面要紧跟真实 token,不能接其他选项",
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
const file = setToken(opts.env, token)
|
|
32
|
+
out.success(
|
|
33
|
+
"config set-token",
|
|
34
|
+
opts.env,
|
|
35
|
+
{ configFile: file, env: opts.env, token: maskToken(token) },
|
|
36
|
+
`✓ 已保存 ${opts.env} 环境 token(${maskToken(token)})到:\n ${file}\n 后续命令会自动使用;临时覆盖可用 --token 或环境变量 SAAS_TOKEN`
|
|
37
|
+
)
|
|
38
|
+
})
|
|
39
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// commands/config/show.mjs — 查看当前配置(配置文件位置、各环境 token 来源)
|
|
2
|
+
import fs from "node:fs"
|
|
3
|
+
import { createOutput } from "../../lib/output.mjs"
|
|
4
|
+
import { getConfigPath, readConfig, maskToken } from "../../lib/config.mjs"
|
|
5
|
+
import { ENV_BASE_URLS } from "../../lib/http.mjs"
|
|
6
|
+
|
|
7
|
+
/** @param {import("commander").Command} topic */
|
|
8
|
+
export function register(topic) {
|
|
9
|
+
topic
|
|
10
|
+
.command("show")
|
|
11
|
+
.description("查看当前配置:配置文件位置、各环境 token(打码)")
|
|
12
|
+
.option("--json", "输出结构化 JSON(默认输出人读摘要)")
|
|
13
|
+
.action(async function () {
|
|
14
|
+
const opts = this.opts()
|
|
15
|
+
const out = createOutput({ json: Boolean(opts.json) })
|
|
16
|
+
const file = getConfigPath()
|
|
17
|
+
const exists = fs.existsSync(file)
|
|
18
|
+
const cfg = readConfig()
|
|
19
|
+
const envVarSet = Boolean(process.env.SAAS_TOKEN)
|
|
20
|
+
|
|
21
|
+
const profiles = Object.fromEntries(
|
|
22
|
+
Object.keys(ENV_BASE_URLS).map((env) => {
|
|
23
|
+
const p = cfg?.profiles?.[env]
|
|
24
|
+
return [env, p?.token ? { token: maskToken(p.token), updatedAt: p.updatedAt ?? null } : null]
|
|
25
|
+
})
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
const lines = [
|
|
29
|
+
`配置文件:${file}${exists ? "" : "(尚未创建)"}`,
|
|
30
|
+
`环境变量 SAAS_TOKEN:${envVarSet ? `已设置(${maskToken(process.env.SAAS_TOKEN)})` : "未设置"}`,
|
|
31
|
+
"各环境 token(优先级:命令行 --token > 环境变量 SAAS_TOKEN > 配置文件):",
|
|
32
|
+
...Object.keys(ENV_BASE_URLS).map((env) => {
|
|
33
|
+
const p = profiles[env]
|
|
34
|
+
return ` ${env.padEnd(5)} ${p ? `${p.token}(配置文件${p.updatedAt ? ",更新于 " + p.updatedAt : ""})` : "(未配置)"}`
|
|
35
|
+
}),
|
|
36
|
+
]
|
|
37
|
+
out.success("config show", "all", { configFile: file, configExists: exists, envVarSAAS_TOKEN: envVarSet, profiles }, lines.join("\n"))
|
|
38
|
+
})
|
|
39
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// commands/reservation/_shared.mjs — reservation 主题内部共用:患者/医生/预约项目解析
|
|
2
|
+
// (文件名以 _ 开头,不会被 cli.mjs 当作命令注册)
|
|
3
|
+
import { CliError, EXIT_CODES } from "../../lib/errors.mjs"
|
|
4
|
+
import { pickOne } from "../../lib/match.mjs"
|
|
5
|
+
import { assertValueNotOptionLike } from "../../lib/options.mjs"
|
|
6
|
+
import { searchPatients } from "../../api/custom.mjs"
|
|
7
|
+
import { listDoctors } from "../../api/employee.mjs"
|
|
8
|
+
import { listTreatItems } from "../../api/treat-item.mjs"
|
|
9
|
+
import { listMarksByRange } from "../../api/custom-mark.mjs"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 解析患者:
|
|
13
|
+
* - --patient-code:直接指定,支持患者编码(customCode)或病历号(codeNum)——
|
|
14
|
+
* 先精确搜索识别一次(后端搜索支持病历号、不支持按 customCode 搜),命中用真实信息,未命中按原值当 customCode 直用
|
|
15
|
+
* - --patient:先搜索(匹配 姓名 / 手机号 / 患者编码 / 病历号,与前端患者搜索框一致),要求唯一命中(重名报错列候选)
|
|
16
|
+
* 注:按患者查询预约用 customMarkListByCustom(customCode + 时间范围),不依赖姓名/手机号。
|
|
17
|
+
* @param {import("../../lib/types.mjs").Http} http
|
|
18
|
+
* @param {{patient?: string, patientCode?: string}} params
|
|
19
|
+
* @returns {Promise<{customCode: string, customName: string|null, customPhone: string|null, raw?: import("../../lib/types.mjs").Patient}>}
|
|
20
|
+
*/
|
|
21
|
+
export async function resolvePatient(http, { patient, patientCode }) {
|
|
22
|
+
if (patientCode) {
|
|
23
|
+
assertValueNotOptionLike(patientCode, {
|
|
24
|
+
option: "--patient-code",
|
|
25
|
+
hint: "例:--patient-code YONGCE26073100001;可先用 saas-cli reservation patients --keyword <关键词> 查看编码",
|
|
26
|
+
})
|
|
27
|
+
try {
|
|
28
|
+
const res = await searchPatients(http, { keyword: patientCode })
|
|
29
|
+
const hit = (res.data?.list ?? []).find(
|
|
30
|
+
(p) => p.customCode === patientCode || p.codeNum === patientCode
|
|
31
|
+
)
|
|
32
|
+
if (hit) {
|
|
33
|
+
return { customCode: hit.customCode, customName: hit.customName ?? null, customPhone: hit.customPhone || null, raw: hit }
|
|
34
|
+
}
|
|
35
|
+
} catch {
|
|
36
|
+
// 忽略:搜索失败时按原值直用
|
|
37
|
+
}
|
|
38
|
+
return { customCode: String(patientCode), customName: null, customPhone: null }
|
|
39
|
+
}
|
|
40
|
+
if (!patient) {
|
|
41
|
+
throw new CliError("缺少患者参数", {
|
|
42
|
+
exitCode: EXIT_CODES.USAGE,
|
|
43
|
+
hint: "用 --patient <姓名/手机号/病历号> 或 --patient-code <编码或病历号> 指定患者",
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
const res = await searchPatients(http, { keyword: patient })
|
|
47
|
+
const list = res.data?.list ?? []
|
|
48
|
+
const hit = pickOne(list, {
|
|
49
|
+
match: (it) => [it.customName, it.customPhone, it.customCode, it.codeNum],
|
|
50
|
+
describe: (it) => `${it.customName} 手机:${it.customPhone ?? "-"} 病历号:${it.codeNum ?? "-"} 编码:${it.customCode}`,
|
|
51
|
+
input: patient,
|
|
52
|
+
label: "患者",
|
|
53
|
+
hint: "可改用 --patient-code <编码或病历号> 直接指定;或先运行 saas-cli reservation patients --keyword <关键词> 查看候选",
|
|
54
|
+
})
|
|
55
|
+
return {
|
|
56
|
+
customCode: hit.customCode,
|
|
57
|
+
customName: hit.customName ?? null,
|
|
58
|
+
customPhone: hit.customPhone || null,
|
|
59
|
+
raw: hit,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 解析医生:--doctor-id 直用;--doctor 从医生列表按姓名匹配
|
|
65
|
+
* @param {import("../../lib/types.mjs").Http} http
|
|
66
|
+
* @param {{doctor?: string, doctorId?: string}} params
|
|
67
|
+
* @returns {Promise<{doctorId: string, doctorName: string, raw?: import("../../lib/types.mjs").Doctor}>}
|
|
68
|
+
*/
|
|
69
|
+
export async function resolveDoctorId(http, { doctor, doctorId }) {
|
|
70
|
+
if (doctorId) {
|
|
71
|
+
assertValueNotOptionLike(doctorId, {
|
|
72
|
+
option: "--doctor-id",
|
|
73
|
+
hint: "例:--doctor-id 26051131994;可先用 saas-cli reservation doctors 查看 userUid",
|
|
74
|
+
})
|
|
75
|
+
return { doctorId: String(doctorId), doctorName: String(doctorId) }
|
|
76
|
+
}
|
|
77
|
+
if (!doctor) {
|
|
78
|
+
throw new CliError("缺少医生参数", {
|
|
79
|
+
exitCode: EXIT_CODES.USAGE,
|
|
80
|
+
hint: "用 --doctor <姓名> 或 --doctor-id <userUid> 指定预约医生",
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
const res = await listDoctors(http)
|
|
84
|
+
const list = res.data ?? []
|
|
85
|
+
const hit = pickOne(list, {
|
|
86
|
+
match: (it) => [it.storeUserName, it.userName],
|
|
87
|
+
describe: (it) => `${it.storeUserName ?? it.userName} userUid:${it.userUid}`,
|
|
88
|
+
input: doctor,
|
|
89
|
+
label: "医生",
|
|
90
|
+
hint: "可改用 --doctor-id <userUid> 直接指定;或先运行 saas-cli reservation doctors 查看列表",
|
|
91
|
+
})
|
|
92
|
+
return { doctorId: String(hit.userUid), doctorName: hit.storeUserName ?? hit.userName, raw: hit }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 解析预约项目:
|
|
97
|
+
* - --items-json 直接使用(仅校验为合法非空 JSON 数组)
|
|
98
|
+
* - --items "洗牙,补牙" 逐个按名称匹配二级项目,组装为 [{up_id, up_title, id, title}]
|
|
99
|
+
* @param {import("../../lib/types.mjs").Http} http
|
|
100
|
+
* @param {{items?: string, itemsJson?: string}} params
|
|
101
|
+
* @returns {Promise<import("../../lib/types.mjs").TreatItemJsonItem[]>}
|
|
102
|
+
*/
|
|
103
|
+
export async function resolveTreatItems(http, { items, itemsJson }) {
|
|
104
|
+
if (itemsJson) {
|
|
105
|
+
let arr
|
|
106
|
+
try {
|
|
107
|
+
arr = JSON.parse(itemsJson)
|
|
108
|
+
} catch (e) {
|
|
109
|
+
throw new CliError(`--items-json 不是合法 JSON:${e.message}`, {
|
|
110
|
+
exitCode: EXIT_CODES.USAGE,
|
|
111
|
+
hint: '期望格式:[{"up_id":13,"up_title":"修复","id":1302,"title":"取模"}]',
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
if (!Array.isArray(arr) || arr.length === 0) {
|
|
115
|
+
throw new CliError("--items-json 必须是非空数组", {
|
|
116
|
+
exitCode: EXIT_CODES.USAGE,
|
|
117
|
+
hint: '期望格式:[{"up_id":13,"up_title":"修复","id":1302,"title":"取模"}]',
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
return arr
|
|
121
|
+
}
|
|
122
|
+
if (!items) {
|
|
123
|
+
throw new CliError("缺少预约项目参数", {
|
|
124
|
+
exitCode: EXIT_CODES.USAGE,
|
|
125
|
+
hint: '用 --items "洗牙,补牙" 指定项目名称(逗号分隔),或 --items-json 直接指定 JSON',
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
const res = await listTreatItems(http)
|
|
129
|
+
const flat = res.data ?? []
|
|
130
|
+
const level1 = new Map(flat.filter((i) => i.level === 1).map((i) => [i.id, i]))
|
|
131
|
+
const level2 = flat.filter((i) => i.level === 2)
|
|
132
|
+
const names = String(items)
|
|
133
|
+
.split(/[,,]+/)
|
|
134
|
+
.map((s) => s.trim())
|
|
135
|
+
.filter(Boolean)
|
|
136
|
+
return names.map((name) => {
|
|
137
|
+
const hit = pickOne(level2, {
|
|
138
|
+
match: (it) => [it.treatItemName],
|
|
139
|
+
describe: (it) => `${it.treatItemName}(分类:${level1.get(it.upTreatItemId)?.treatItemName ?? "-"}) id:${it.id}`,
|
|
140
|
+
input: name,
|
|
141
|
+
label: "预约项目",
|
|
142
|
+
hint: "可先运行 saas-cli reservation items --keyword <关键词> 查看项目,或用 --items-json 直接指定",
|
|
143
|
+
})
|
|
144
|
+
return {
|
|
145
|
+
up_id: hit.upTreatItemId,
|
|
146
|
+
up_title: level1.get(hit.upTreatItemId)?.treatItemName ?? "",
|
|
147
|
+
id: hit.id,
|
|
148
|
+
title: hit.treatItemName,
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 预约查询响应里的时间是 ISO 格式("2026-10-15T10:00:00"),统一转成 "2026-10-15 10:00:00" 便于比较与展示
|
|
155
|
+
* @param {string|undefined|null} s
|
|
156
|
+
* @returns {string}
|
|
157
|
+
*/
|
|
158
|
+
export function normTime(s) {
|
|
159
|
+
return String(s ?? "").replace("T", " ")
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* 预约状态显示名:照前端看板卡片的 statusName 逻辑(主键是 visitStatus,不是 status)
|
|
164
|
+
* 来源:前端 src/views/reservation/components/board/index.tsx 的状态改写逻辑(2026-10 核对)
|
|
165
|
+
* @param {import("../../lib/types.mjs").CustomMark} m 预约记录(需含 visitStatus/status)
|
|
166
|
+
* @returns {string}
|
|
167
|
+
*/
|
|
168
|
+
export function markStatusName(m) {
|
|
169
|
+
const vs = m?.visitStatus
|
|
170
|
+
const byVisit = { 1: "预约未到", 2: "待分诊", 3: "治疗中", 4: "待收费", 5: "已完成", 6: "已离店" }
|
|
171
|
+
let name = byVisit[vs] ?? (vs !== undefined ? `未知状态(${vs})` : "-")
|
|
172
|
+
if (vs === 1) name = "已确认"
|
|
173
|
+
if (vs === 1 && m?.status === 1) name = "已预约"
|
|
174
|
+
if (vs === 2) name = "已到店"
|
|
175
|
+
if (vs === 3) name = "治疗中"
|
|
176
|
+
if (vs === 4 && (m?.status === 5 || m?.status === 2)) name = "治疗中"
|
|
177
|
+
if (vs === 5) name = "治疗完成"
|
|
178
|
+
if (vs === 6) name = "已离店"
|
|
179
|
+
return name
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** 解析 treatItemJson 字符串 → 项目名列表(解析失败返回空数组) */
|
|
183
|
+
export function parseItemTitles(treatItemJson) {
|
|
184
|
+
try {
|
|
185
|
+
const arr = JSON.parse(treatItemJson || "[]")
|
|
186
|
+
return Array.isArray(arr) ? arr.map((i) => i?.title).filter(Boolean) : []
|
|
187
|
+
} catch {
|
|
188
|
+
return []
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* 回查新建的预约,拿完整预约信息(创建响应里虽带 data.id,但没有医生名 / 状态等展示字段)。
|
|
194
|
+
* 照前端"取当天最近预约"的做法:customMarkListByCustom + 当天时间范围(服务端过滤),
|
|
195
|
+
* 取开始时间相同的记录;失败返回 null(不影响创建结果)。
|
|
196
|
+
* @param {import("../../lib/types.mjs").Http} http
|
|
197
|
+
* @param {{customCode: string, markStartTime: string}} params
|
|
198
|
+
* @returns {Promise<import("../../lib/types.mjs").CustomMark|null>}
|
|
199
|
+
*/
|
|
200
|
+
export async function findCreatedMark(http, { customCode, markStartTime }) {
|
|
201
|
+
try {
|
|
202
|
+
const target = normTime(markStartTime).slice(0, 16)
|
|
203
|
+
const date = target.slice(0, 10)
|
|
204
|
+
const res = await listMarksByRange(http, {
|
|
205
|
+
customCode,
|
|
206
|
+
startTime: `${date} 00:00:00`,
|
|
207
|
+
endTime: `${date} 23:59:59`,
|
|
208
|
+
})
|
|
209
|
+
const sameTime = (res.data?.cmList ?? []).filter(
|
|
210
|
+
(m) => normTime(m?.markStartTime).slice(0, 16) === target
|
|
211
|
+
)
|
|
212
|
+
return sameTime.sort((a, b) => Number(b?.id ?? 0) - Number(a?.id ?? 0))[0] ?? null
|
|
213
|
+
} catch {
|
|
214
|
+
return null
|
|
215
|
+
}
|
|
216
|
+
}
|