@stelfield/cli 0.0.0-stage → 0.12.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/LICENSE +21 -0
- package/README.md +327 -2
- package/bin/stelfield.js +11 -0
- package/package.json +36 -4
- package/src/api.js +1088 -0
- package/src/atomic-write.js +44 -0
- package/src/auto-accept.js +74 -0
- package/src/brand.js +20 -0
- package/src/callback-pages.js +175 -0
- package/src/cli-parse.js +639 -0
- package/src/client-version.js +83 -0
- package/src/collab-input.js +116 -0
- package/src/config.js +17 -0
- package/src/connect.js +416 -0
- package/src/cont-commands.js +264 -0
- package/src/credentials.js +70 -0
- package/src/error-map.js +126 -0
- package/src/exit-reasons.js +65 -0
- package/src/extension-commands.js +236 -0
- package/src/file-deliver.js +220 -0
- package/src/format.js +1454 -0
- package/src/index.js +1896 -0
- package/src/input-limits.js +84 -0
- package/src/libra-commands.js +681 -0
- package/src/local-media-refs.js +228 -0
- package/src/media-commands.js +121 -0
- package/src/media-safety.js +160 -0
- package/src/media-upload.js +478 -0
- package/src/oauth.js +327 -0
- package/src/pkce.js +47 -0
- package/src/poll-state.js +88 -0
- package/src/probe.js +155 -0
- package/src/profile-intro-input.js +108 -0
- package/src/profile.js +62 -0
- package/src/quote-pass.js +133 -0
- package/src/refund-commands.js +339 -0
- package/src/retired-commands.js +25 -0
- package/src/web-deliver.js +332 -0
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
// Libra dispute-ruling commands: stelfield libra quote|file|status|statement|answer|ruling|accept|review quote|review file.
|
|
2
|
+
//
|
|
3
|
+
// Thin wrapper like the rest of the CLI: parse flags -> call the platform -> print. The only logic that lives
|
|
4
|
+
// here is what must be checked on the machine itself: the amount the user confirms has to equal the quote,
|
|
5
|
+
// and a quote that has expired is refused before anything is sent.
|
|
6
|
+
//
|
|
7
|
+
// Exit codes (docs: Agent command-line guide, section 1):
|
|
8
|
+
// 0 ok 1 other failure 2 local parameter problem
|
|
9
|
+
// 3 quote expired / not found / amount differs from the quote -> run `quote` again
|
|
10
|
+
// 4 the task or case is not in a state that allows this (not pending review / a case exists already ...)
|
|
11
|
+
// 5 human required: filing a case / applying for a review can only be done by the person on the web page
|
|
12
|
+
// (`libra file` and `libra review file` send nothing; they print the web link to hand to the user)
|
|
13
|
+
// --json prints exactly one JSON object on stdout ({ok:true,...} or {ok:false,error:{code,message}}).
|
|
14
|
+
//
|
|
15
|
+
// P19 early close (before the ruling is out): stelfield libra withdraw | concede | settle propose|accept|reject|withdraw.
|
|
16
|
+
// * withdraw (the buyer takes the case back) is web only: an Agent token gets 403 agent_token_forbidden, which is
|
|
17
|
+
// shown as "a person must do this on the web" (exit 5) together with the page link.
|
|
18
|
+
// * concede and settle accept must carry --expect-pct, the percentage the Agent read; the platform refuses the call
|
|
19
|
+
// when the claim / offer has changed since (exit 3: read `libra status` again).
|
|
20
|
+
|
|
21
|
+
import { BRAND } from "./brand.js";
|
|
22
|
+
import { readFileSync } from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { atomicWriteFile } from './atomic-write.js';
|
|
25
|
+
import { resolveCredDir } from './profile.js';
|
|
26
|
+
import {
|
|
27
|
+
libraQuote, libraTaskCase, libraStatement, libraAddEvidence, libraAnswer,
|
|
28
|
+
libraRulings, libraAccept, libraReviewQuote,
|
|
29
|
+
libraGetCase, libraWithdrawCase, libraConcede, libraProposeOffer, libraAcceptOffer, libraRejectOffer, libraWithdrawOffer,
|
|
30
|
+
} from './api.js';
|
|
31
|
+
import { EXIT_HUMAN_REQUIRED, isHumanRequiredError } from './exit-reasons.js';
|
|
32
|
+
|
|
33
|
+
export const STATEMENT_MAX_CHARS = 1000;
|
|
34
|
+
const MAX_PAGES_PER_FILE = 5000;
|
|
35
|
+
|
|
36
|
+
// Exit code of "a person must do this on the web page" (filing a case, applying for a review, withdrawing a case).
|
|
37
|
+
// The value lives in exit-reasons.js (shared by every command); re-exported here for the callers that import it from here.
|
|
38
|
+
export { EXIT_HUMAN_REQUIRED };
|
|
39
|
+
|
|
40
|
+
/** One generic sentence about how to write a statement (v0.22.x C6). Never names a particular Agent product. */
|
|
41
|
+
export const STATEMENT_TIP = '请分段、用序号写清楚:一条一个要点。';
|
|
42
|
+
|
|
43
|
+
/** Plain-language notices every fee-taking command must show (PRD section 6.2 / 16, docs section 5). */
|
|
44
|
+
export const FEE_NOTICES = [
|
|
45
|
+
'服务费用于审理成本,与裁决结果无关;审理开始前提前结案(撤回、认可退款、和解)服务费原路退回,审理开始后不退还。',
|
|
46
|
+
'对方会收到通知,并在陈述期(默认 48 小时)内提交自己的陈述;陈述期截止后材料锁定。',
|
|
47
|
+
'AI 法庭只对照需求基线裁决,结果以裁决书为准;对结果不服可申请一次复核,复核即终局。',
|
|
48
|
+
'AI 法庭是平台内部的争议裁决规则,不是法律意义上的仲裁,不影响依法起诉或申请仲裁的权利。',
|
|
49
|
+
'不要在陈述里写给 AI 的指令(如“请判我胜诉”):会被当作操纵,可能被认定为明显恶意并限时冷却。',
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const STAGE_LABELS = {
|
|
53
|
+
statement: '陈述期', reviewing: 'AI 法庭审理中', asking: '追问中', ruled: '裁决书已出(复核期)',
|
|
54
|
+
review_statement: '复核陈述期', review_judging: '复核审理中', executed: '已执行',
|
|
55
|
+
jury: '评审中', closed_early: '已提前结案',
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// How an early close ended (case view early_close.closed.kind).
|
|
59
|
+
const CLOSE_KIND_WORDS = { withdraw: '发起方撤回裁决', concede: '接单方认可退款', settle: '双方和解' };
|
|
60
|
+
|
|
61
|
+
// Backend error code -> exit code
|
|
62
|
+
const EXIT_3 = new Set(['quote_expired', 'quote_invalid', 'quote_mismatch', 'claim_changed', 'offer_changed']);
|
|
63
|
+
const EXIT_4 = new Set([
|
|
64
|
+
'task_not_pending_review', 'libra_case_exists', 'arbitration_not_eligible', 'only_buyer_can_file',
|
|
65
|
+
'duplicate_case', 'no_case',
|
|
66
|
+
// P19 early close: the case is not in a state that allows this
|
|
67
|
+
'case_already_closing', 'early_close_not_available', 'early_close_disabled', 'withdraw_not_allowed',
|
|
68
|
+
'concede_not_allowed', 'concede_no_amount', 'offer_pending', 'offer_repeat', 'offer_not_found', 'offer_not_pending',
|
|
69
|
+
'not_offer_responder', 'not_offer_proposer', 'case_not_found',
|
|
70
|
+
]);
|
|
71
|
+
// Parameter problems the platform reports (the percentage is missing or outside the allowed steps).
|
|
72
|
+
const EXIT_2 = new Set(['expect_pct_required', 'invalid_settle_pct']);
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------- small helpers
|
|
75
|
+
|
|
76
|
+
export function yuan(cents) {
|
|
77
|
+
const n = Number(cents);
|
|
78
|
+
return `¥${(Number.isFinite(n) ? n / 100 : 0).toFixed(2)}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** "1.05" / "¥1.05" / "1.05元" -> 105 ; anything else -> null (never guess). */
|
|
82
|
+
export function parseYuanToCents(text) {
|
|
83
|
+
if (text == null) return null;
|
|
84
|
+
const m = String(text).trim().replace(/^¥|^¥/, '').replace(/元$/, '').trim().match(/^(\d+)(?:\.(\d{1,2}))?$/);
|
|
85
|
+
if (!m) return null;
|
|
86
|
+
return Number(m[1]) * 100 + Number((m[2] || '').padEnd(2, '0') || 0);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function fmtTime(iso) {
|
|
90
|
+
if (!iso) return '-';
|
|
91
|
+
const d = new Date(iso);
|
|
92
|
+
if (Number.isNaN(d.getTime())) return String(iso);
|
|
93
|
+
return d.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false }).replace(/\//g, '-') + '(北京时间)';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** "3-6,9" -> [3,4,5,6,9]; null when malformed or absurdly large. */
|
|
97
|
+
export function parsePageRanges(spec) {
|
|
98
|
+
const out = [];
|
|
99
|
+
for (const part of String(spec || '').split(',')) {
|
|
100
|
+
const m = part.trim().match(/^(\d+)(?:-(\d+))?$/);
|
|
101
|
+
if (!m) return null;
|
|
102
|
+
const a = Number(m[1]);
|
|
103
|
+
const b = m[2] ? Number(m[2]) : a;
|
|
104
|
+
if (a < 1 || b < a || out.length + (b - a + 1) > MAX_PAGES_PER_FILE) return null;
|
|
105
|
+
for (let p = a; p <= b; p += 1) out.push(p);
|
|
106
|
+
}
|
|
107
|
+
return out.length ? out : null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** "12-18" -> [12,18] minutes; null when malformed. */
|
|
111
|
+
export function parseMinuteRange(spec) {
|
|
112
|
+
const m = String(spec || '').trim().match(/^(\d+(?:\.\d+)?)-(\d+(?:\.\d+)?)$/);
|
|
113
|
+
if (!m) return null;
|
|
114
|
+
const a = Number(m[1]);
|
|
115
|
+
const b = Number(m[2]);
|
|
116
|
+
return b > a ? [a, b] : null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** "partial:30" -> {mode:'partial', pct:30}; null when malformed. */
|
|
120
|
+
export function parseClaim(spec) {
|
|
121
|
+
const v = String(spec || 'libra').trim().toLowerCase();
|
|
122
|
+
if (v === 'full' || v === 'libra') return { mode: v, pct: null };
|
|
123
|
+
const m = v.match(/^partial:(\d{1,3})%?$/);
|
|
124
|
+
if (!m) return null;
|
|
125
|
+
const pct = Number(m[1]);
|
|
126
|
+
return pct >= 10 && pct <= 90 && pct % 10 === 0 ? { mode: 'partial', pct } : null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function claimLabel(c) {
|
|
130
|
+
if (!c) return '由 AI 法庭判断';
|
|
131
|
+
if (c.mode === 'full') return '全额退款';
|
|
132
|
+
if (c.mode === 'partial') return `部分退款 ${c.pct}%`;
|
|
133
|
+
return '由 AI 法庭判断';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function splitKeyed(spec) {
|
|
137
|
+
const i = String(spec).indexOf(':');
|
|
138
|
+
return i > 0 ? [spec.slice(0, i).trim(), spec.slice(i + 1).trim()] : [null, null];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------- local quote cache (amount confirmation)
|
|
142
|
+
|
|
143
|
+
function cachePath(credOpts) {
|
|
144
|
+
return path.join(resolveCredDir(credOpts), 'libra-quotes.json');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function readCache(credOpts) {
|
|
148
|
+
try {
|
|
149
|
+
const j = JSON.parse(readFileSync(cachePath(credOpts), 'utf8'));
|
|
150
|
+
return j && typeof j === 'object' && j.quotes && typeof j.quotes === 'object' ? j : { quotes: {} };
|
|
151
|
+
} catch {
|
|
152
|
+
return { quotes: {} };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function saveQuote(credOpts, quoteId, entry) {
|
|
157
|
+
const cache = readCache(credOpts);
|
|
158
|
+
const now = Date.now();
|
|
159
|
+
const alive = Object.entries(cache.quotes)
|
|
160
|
+
.filter(([, v]) => v && now - Number(v.saved_at || 0) < 24 * 3600 * 1000)
|
|
161
|
+
.slice(-49);
|
|
162
|
+
cache.quotes = Object.fromEntries(alive);
|
|
163
|
+
cache.quotes[quoteId] = { ...entry, saved_at: now };
|
|
164
|
+
try {
|
|
165
|
+
atomicWriteFile(cachePath(credOpts), JSON.stringify(cache), 0o600);
|
|
166
|
+
} catch { /* cache is best effort; `file` then asks for a fresh quote */ }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------- output / errors
|
|
170
|
+
|
|
171
|
+
function mapError(e) {
|
|
172
|
+
// the body is flat ({error, message}) or FastAPI-style ({detail: {error, message}}); read both
|
|
173
|
+
const raw = e && e.body && typeof e.body === 'object' ? e.body : {};
|
|
174
|
+
const b = raw.detail && typeof raw.detail === 'object' ? raw.detail : raw;
|
|
175
|
+
const code = typeof b.error === 'string' ? b.error : null;
|
|
176
|
+
const message = b.message || (e && e.message) || String(e);
|
|
177
|
+
if (code && isHumanRequiredError(code)) return { exit: EXIT_HUMAN_REQUIRED, human: true, code, message, webUrl: b.web_url || null };
|
|
178
|
+
if (code && (code === 'claim_changed' || code === 'offer_changed')) {
|
|
179
|
+
return { exit: 3, code, message: `${message}(请先运行 stelfield libra status 读到最新比例,再重新决定)` };
|
|
180
|
+
}
|
|
181
|
+
if (code && EXIT_3.has(code)) return { exit: 3, code, message: `${message}(请重新运行 stelfield libra quote / review quote)` };
|
|
182
|
+
if (code && EXIT_2.has(code)) return { exit: 2, code, message };
|
|
183
|
+
if (code && EXIT_4.has(code)) return { exit: 4, code, message };
|
|
184
|
+
return { exit: 1, code: code || 'request_failed', message };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function makeIo(ctx) {
|
|
188
|
+
const { flags, log, err } = ctx;
|
|
189
|
+
return {
|
|
190
|
+
ok(data, text) { log(flags.json ? JSON.stringify({ ok: true, ...data }) : text); return 0; },
|
|
191
|
+
fail(exit, code, message) {
|
|
192
|
+
if (flags.json) log(JSON.stringify({ ok: false, error: { code, message } }));
|
|
193
|
+
else err(message);
|
|
194
|
+
return exit;
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function readText(file, inline, what, io) {
|
|
200
|
+
if (file) {
|
|
201
|
+
try { return { text: readFileSync(file, 'utf8').trim() }; } catch { return { fail: io.fail(2, 'file_unreadable', `读不了${what}文件:${file}`) }; }
|
|
202
|
+
}
|
|
203
|
+
return { text: inline == null ? '' : String(inline).trim() };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const cpLen = (s) => Array.from(s).length;
|
|
207
|
+
|
|
208
|
+
/** Find the case of a task. Returns { caseView, info } or { fail }. */
|
|
209
|
+
async function loadCase(ctx, io, taskId) {
|
|
210
|
+
const info = await libraTaskCase(ctx.baseUrl, ctx.credOpts, taskId, { fetchImpl: ctx.fetchImpl });
|
|
211
|
+
if (!info || !info.case) {
|
|
212
|
+
return { fail: io.fail(4, 'no_case', info && info.can_file
|
|
213
|
+
? '这个任务还没有裁决案件。发起方要先在网页和对方协商退款(stelfield refund status 看现状),谈不拢再由本人申请裁决。'
|
|
214
|
+
: '这个任务还没有裁决案件。') };
|
|
215
|
+
}
|
|
216
|
+
return { caseView: info.case, info };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function webBase(ctx) {
|
|
220
|
+
return (ctx.env && ctx.env.STELFIELD_WEB_BASE) || BRAND.siteUrl;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Web page where the person files the case / applies for the review. Never a link that submits anything. */
|
|
224
|
+
function applyWebUrl(ctx, taskId, fromPlatform) {
|
|
225
|
+
// Asking the AI court always starts from the refund negotiation page (negotiate first, rule later).
|
|
226
|
+
return fromPlatform ? `${webBase(ctx)}${fromPlatform}` : `${webBase(ctx)}/src/pages/refund-neg/index.html?task=${encodeURIComponent(taskId)}`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function reviewWebUrl(ctx, caseId, fromPlatform) {
|
|
230
|
+
if (fromPlatform) return `${webBase(ctx)}${fromPlatform}`;
|
|
231
|
+
return caseId ? `${webBase(ctx)}/src/pages/libra-review/index.html?id=${encodeURIComponent(caseId)}` : null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Print the "ask the person" notice (or one JSON object) and return the human-required exit code. */
|
|
235
|
+
function humanRequired(ctx, what, webUrl, extra = {}, lines = []) {
|
|
236
|
+
const message = `${what}需要本人在网页完成,Agent 不能代为提交。请把下面的链接交给用户。`;
|
|
237
|
+
if (ctx.flags.json) {
|
|
238
|
+
ctx.log(JSON.stringify({ ok: false, error: 'human_required', message, web_url: webUrl, ...extra }));
|
|
239
|
+
} else {
|
|
240
|
+
ctx.log([message, ...(webUrl ? [` 网页:${webUrl}`] : []), ...lines].join('\n'));
|
|
241
|
+
}
|
|
242
|
+
return EXIT_HUMAN_REQUIRED;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function cashierUrl(ctx, order) {
|
|
246
|
+
const p = order && order.cashier && order.cashier.path;
|
|
247
|
+
return p ? `${webBase(ctx)}${p}` : null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function evidenceInputs(flags, io) {
|
|
251
|
+
const pages = new Map();
|
|
252
|
+
for (const spec of flags.pages) {
|
|
253
|
+
const [fid, rng] = splitKeyed(spec);
|
|
254
|
+
const list = fid ? parsePageRanges(rng) : null;
|
|
255
|
+
if (!list) return { fail: io.fail(2, 'bad_pages', `--pages 格式不对:${spec}(应为 <file-id>:3-6,9)`) };
|
|
256
|
+
pages.set(fid, list);
|
|
257
|
+
}
|
|
258
|
+
const minutes = new Map();
|
|
259
|
+
for (const spec of flags.minutes) {
|
|
260
|
+
const [fid, rng] = splitKeyed(spec);
|
|
261
|
+
const r = fid ? parseMinuteRange(rng) : null;
|
|
262
|
+
if (!r) return { fail: io.fail(2, 'bad_minutes', `--minutes 格式不对:${spec}(应为 <file-id>:起-止,单位分钟)`) };
|
|
263
|
+
minutes.set(fid, r);
|
|
264
|
+
}
|
|
265
|
+
const ids = [...new Set(flags.evidence)];
|
|
266
|
+
for (const k of [...pages.keys(), ...minutes.keys()]) {
|
|
267
|
+
if (!ids.includes(k)) return { fail: io.fail(2, 'selection_without_evidence', `--pages / --minutes 里的文件 ${k} 没有出现在 --evidence 里`) };
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
evidence: ids.map((fid) => ({
|
|
271
|
+
file_id: fid,
|
|
272
|
+
...(pages.has(fid) ? { selected_pages: pages.get(fid) } : {}),
|
|
273
|
+
...(minutes.has(fid) ? { selected_minutes: minutes.get(fid) } : {}),
|
|
274
|
+
})),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function quoteLines(q) {
|
|
279
|
+
return (q.lines || []).map((l) => {
|
|
280
|
+
const detail = l.units != null && l.unit_cents != null ? ` ${l.units} × ${yuan(l.unit_cents)}` : '';
|
|
281
|
+
return ` - ${l.label || l.code}${detail} = ${yuan(l.amount_cents)}`;
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---------------------------------------------------------------- commands
|
|
286
|
+
|
|
287
|
+
async function cmdQuote(ctx, io) {
|
|
288
|
+
const { flags } = ctx;
|
|
289
|
+
if (!flags.task) return io.fail(2, 'missing_task', '缺少 --task <task-id>');
|
|
290
|
+
const claim = parseClaim(flags.claim || 'libra');
|
|
291
|
+
if (!claim) return io.fail(2, 'bad_claim', '--claim 只能是 full、partial:<10-90 且为 10 的倍数> 或 libra');
|
|
292
|
+
const ev = evidenceInputs(flags, io);
|
|
293
|
+
if (ev.fail !== undefined) return ev.fail;
|
|
294
|
+
const st = readText(flags.statementFile, flags.statement, '陈述', io);
|
|
295
|
+
if (st.fail !== undefined) return st.fail;
|
|
296
|
+
const q = await libraQuote(ctx.baseUrl, ctx.credOpts,
|
|
297
|
+
{ task_id: flags.task, evidence: ev.evidence, statement: st.text }, { fetchImpl: ctx.fetchImpl });
|
|
298
|
+
saveQuote(ctx.credOpts, q.quote_id, {
|
|
299
|
+
kind: 'filing', task_id: flags.task, claim_mode: claim.mode, claim_pct: claim.pct,
|
|
300
|
+
total_cents: q.total_cents, expires_at: q.expires_at,
|
|
301
|
+
});
|
|
302
|
+
const text = [
|
|
303
|
+
`AI 法庭报价(任务 ${flags.task})——本次不扣费`,
|
|
304
|
+
` 诉求:${claimLabel(claim)}`,
|
|
305
|
+
' 明细:', ...quoteLines(q),
|
|
306
|
+
` 合计:${yuan(q.total_cents)}(信用系数 ${q.credit_coeff})`,
|
|
307
|
+
` 报价单:${q.quote_id}`,
|
|
308
|
+
` 有效至:${fmtTime(q.expires_at)}`,
|
|
309
|
+
'',
|
|
310
|
+
`申请裁决要先协商:先在退款协商页和对方谈退款比例(免费),谈不拢再由用户本人在网页申请裁决(Agent 不能提交)。请把链接交给用户:${applyWebUrl(ctx, flags.task, q.web_url)}`,
|
|
311
|
+
'', '须知:', ...FEE_NOTICES.map((n) => ` · ${n}`),
|
|
312
|
+
].join('\n');
|
|
313
|
+
return io.ok({ quote: q, claim: claim.mode, claim_pct: claim.pct, notices: FEE_NOTICES,
|
|
314
|
+
web_url: applyWebUrl(ctx, flags.task, q.web_url) }, text);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// `libra file` no longer submits anything: filing a case is a paid action only the person may start. It shows the
|
|
318
|
+
// quote the Agent already has (if any) and hands over the web link. --confirm-amount / --statement* are accepted
|
|
319
|
+
// for compatibility with older scripts and ignored.
|
|
320
|
+
async function cmdFile(ctx, io) {
|
|
321
|
+
const { flags } = ctx;
|
|
322
|
+
if (!flags.task) return io.fail(2, 'missing_task', '缺少 --task <task-id>');
|
|
323
|
+
const known = flags.quote ? readCache(ctx.credOpts).quotes[flags.quote] : null;
|
|
324
|
+
const usable = known && known.kind === 'filing' && known.task_id === flags.task
|
|
325
|
+
&& !(known.expires_at && new Date(known.expires_at).getTime() < Date.now());
|
|
326
|
+
const extra = usable ? { quote_id: flags.quote, total_cents: known.total_cents } : {};
|
|
327
|
+
return humanRequired(ctx, '发起裁决(要先协商退款,谈不拢才能申请)', applyWebUrl(ctx, flags.task), extra, [
|
|
328
|
+
...(usable ? [` 报价单 ${flags.quote} 合计 ${yuan(known.total_cents)}(本次未提交、未扣费)`] : [` 还没看过报价?先运行 stelfield libra quote --task ${flags.task}(不扣费)`]),
|
|
329
|
+
' 用户在协商页谈不拢、再确认须知并付款成功后才会立案;你的任务状态不会因此改变。',
|
|
330
|
+
]);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Lines about the early close (withdraw / concede / settle) taken from the case view block `early_close`.
|
|
334
|
+
function earlyCloseLines(ec) {
|
|
335
|
+
if (!ec) return [];
|
|
336
|
+
if (ec.closed) {
|
|
337
|
+
const c = ec.closed;
|
|
338
|
+
const pct = c.refund_pct != null ? `,退还比例 ${c.refund_pct}%` : '';
|
|
339
|
+
return [` 提前结案:${CLOSE_KIND_WORDS[c.kind] || c.kind}${pct}${c.done ? '' : '(款项处理中)'}`,
|
|
340
|
+
...(c.deliverable_returned ? [' 全额退款:交付物已归还接单方,发起方不能再查看或下载'] : [])];
|
|
341
|
+
}
|
|
342
|
+
if (ec.executing) return [' 提前结案:正在结案,款项处理中'];
|
|
343
|
+
if (ec.enabled === false) return [' 提前结案:暂时关闭,审理照常进行'];
|
|
344
|
+
const out = [' 提前结案(出裁决书之前都可以):'];
|
|
345
|
+
const av = ec.available || {};
|
|
346
|
+
if (av.withdraw && av.withdraw.allowed) out.push(' 撤回裁决:发起方可以,但只能本人在网页操作(Agent 不能撤回)');
|
|
347
|
+
if (av.concede && av.concede.allowed && ec.claim && ec.claim.pct != null) {
|
|
348
|
+
out.push(` 认可退款:对方主张退还 ${ec.claim.pct}%;认可请运行 stelfield libra concede --task <task_id> --expect-pct ${ec.claim.pct}`);
|
|
349
|
+
}
|
|
350
|
+
if (ec.settle_range) {
|
|
351
|
+
out.push(` 和解:双方各可提议退还比例,区间 ${ec.settle_range.min}%-${ec.settle_range.max}%,每档 ${ec.settle_range.step}%`);
|
|
352
|
+
}
|
|
353
|
+
const o = ec.pending_offer;
|
|
354
|
+
if (o) {
|
|
355
|
+
out.push(` 待回应的和解提议 #${o.id}:退还发起方 ${o.refund_pct}%(${o.mine ? '我方提出,可撤回' : '对方提出,可接受或拒绝'})`);
|
|
356
|
+
if (!o.mine) out.push(` 接受:stelfield libra settle accept --task <task_id> --expect-pct ${o.refund_pct}`);
|
|
357
|
+
}
|
|
358
|
+
if (ec.fee) {
|
|
359
|
+
out.push(ec.fee.refund_if_closed_now ? ' 服务费:现在结案,审理尚未开始,服务费原路退回'
|
|
360
|
+
: (ec.fee.blocked_by_cooldown ? ' 服务费:近期多次撤回,这次结案服务费不退还'
|
|
361
|
+
: ' 服务费:审理已开始,现在结案服务费不退还'));
|
|
362
|
+
}
|
|
363
|
+
return out;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function cmdStatus(ctx, io) {
|
|
367
|
+
if (!ctx.flags.task) return io.fail(2, 'missing_task', '缺少 --task <task-id>');
|
|
368
|
+
const info = await libraTaskCase(ctx.baseUrl, ctx.credOpts, ctx.flags.task, { fetchImpl: ctx.fetchImpl });
|
|
369
|
+
const c = info && info.case;
|
|
370
|
+
if (!c) {
|
|
371
|
+
const text = info && info.can_file
|
|
372
|
+
? '这个任务还没有裁决案件;你是发起方,可以先 stelfield libra quote 看报价,再请用户本人在网页申请裁决。'
|
|
373
|
+
: '这个任务还没有裁决案件。';
|
|
374
|
+
return io.ok({ task_id: ctx.flags.task, case: null, can_file: !!(info && info.can_file) }, text);
|
|
375
|
+
}
|
|
376
|
+
const subm = c.statement_submitted || {};
|
|
377
|
+
const orders = (c.fee_orders || []).filter((o) => o.mine);
|
|
378
|
+
const lines = [
|
|
379
|
+
`任务 ${c.task_id} 的裁决案件 ${c.id}`,
|
|
380
|
+
` 阶段:${STAGE_LABELS[c.stage] || c.stage}${c.paused ? '(暂停中,恢复后计时继续)' : ''}`,
|
|
381
|
+
` 你是:${c.you_are === 'buyer' ? '发起方' : '接单方'}`,
|
|
382
|
+
...(c.stage === 'statement' ? [` 陈述期截止:${fmtTime(c.statement_deadline)}`] : []),
|
|
383
|
+
...(c.stage === 'asking' ? [` 追问回答截止:${fmtTime(c.ask_deadline)}`] : []),
|
|
384
|
+
...(c.cooling_deadline ? [` 复核期截止:${fmtTime(c.cooling_deadline)}`] : []),
|
|
385
|
+
` 陈述:发起方${subm.buyer ? '已提交' : '未提交'},接单方${subm.seller ? '已提交' : '未提交'}`,
|
|
386
|
+
...(c.statement_limits ? [` 你的补充次数:${c.statement_limits.appends_used}/${c.statement_limits.appends_max},单次不超过 ${c.statement_limits.chars_max} 字`] : []),
|
|
387
|
+
...(orders.length ? [' 你的服务费:', ...orders.map((o) => ` - ${o.kind} ${yuan(o.amount_cents)} ${o.status}`)] : []),
|
|
388
|
+
` 复核轮次:${c.review_round || 0}`,
|
|
389
|
+
...earlyCloseLines(c.early_close),
|
|
390
|
+
].join('\n');
|
|
391
|
+
return io.ok({ case: c, can_file: !!info.can_file }, lines);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function cmdStatement(ctx, io) {
|
|
395
|
+
const { flags } = ctx;
|
|
396
|
+
if (!flags.task) return io.fail(2, 'missing_task', '缺少 --task <task-id>');
|
|
397
|
+
const st = readText(flags.textFile, flags.text[0], '陈述', io);
|
|
398
|
+
if (st.fail !== undefined) return st.fail;
|
|
399
|
+
const evid = flags.evidence.length ? evidenceInputs(flags, io) : { evidence: [] };
|
|
400
|
+
if (evid.fail !== undefined) return evid.fail;
|
|
401
|
+
if (!st.text && !evid.evidence.length) return io.fail(2, 'nothing_to_submit', '没有内容:--text-file <路径> / --text "文字",或 --evidence <file-id>');
|
|
402
|
+
if (cpLen(st.text) > STATEMENT_MAX_CHARS) return io.fail(2, 'statement_too_long', `单次陈述不能超过 ${STATEMENT_MAX_CHARS} 字(现在 ${cpLen(st.text)} 字)`);
|
|
403
|
+
const lc = await loadCase(ctx, io, flags.task);
|
|
404
|
+
if (lc.fail !== undefined) return lc.fail;
|
|
405
|
+
const caseId = lc.caseView.id;
|
|
406
|
+
const out = {};
|
|
407
|
+
const lines = [];
|
|
408
|
+
if (st.text) {
|
|
409
|
+
out.statement = await libraStatement(ctx.baseUrl, ctx.credOpts, caseId, st.text, flags.idempotencyKey || undefined, { fetchImpl: ctx.fetchImpl });
|
|
410
|
+
lines.push('陈述已提交(文字免费)。陈述期截止后材料锁定,不能再修改。');
|
|
411
|
+
lines.push(`${STATEMENT_TIP}双方会看到你的原文,换行和分段都会保留。`);
|
|
412
|
+
if (out.statement.review_opened) lines.push('双方都已陈述,审理已提前开始。');
|
|
413
|
+
}
|
|
414
|
+
if (evid.evidence.length) {
|
|
415
|
+
const order = await libraAddEvidence(ctx.baseUrl, ctx.credOpts, caseId, evid.evidence, undefined, { fetchImpl: ctx.fetchImpl });
|
|
416
|
+
out.evidence_order = order;
|
|
417
|
+
const url = cashierUrl(ctx, order);
|
|
418
|
+
lines.push(`补充关键证据需要你支付 ${yuan(order.amount_cents)}(计费证据由补充方承担,不退还),订单号 ${order.order_no || order.order_id}`);
|
|
419
|
+
if (url) lines.push(`收银台:${url}`);
|
|
420
|
+
lines.push('支付成功后证据才会进入案卷。');
|
|
421
|
+
}
|
|
422
|
+
return io.ok({ case_id: caseId, ...out }, lines.join('\n'));
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async function cmdAnswer(ctx, io) {
|
|
426
|
+
const { flags } = ctx;
|
|
427
|
+
if (!flags.task) return io.fail(2, 'missing_task', '缺少 --task <task-id>');
|
|
428
|
+
if (!flags.question) return io.fail(2, 'missing_question', '缺少 --question <q-id>');
|
|
429
|
+
const st = readText(flags.textFile, flags.text[0], '回答', io);
|
|
430
|
+
if (st.fail !== undefined) return st.fail;
|
|
431
|
+
if (!st.text) return io.fail(2, 'missing_text', '缺少回答:--text "回答" 或 --text-file <路径>');
|
|
432
|
+
if (cpLen(st.text) > STATEMENT_MAX_CHARS) return io.fail(2, 'answer_too_long', `回答不能超过 ${STATEMENT_MAX_CHARS} 字`);
|
|
433
|
+
const lc = await loadCase(ctx, io, flags.task);
|
|
434
|
+
if (lc.fail !== undefined) return lc.fail;
|
|
435
|
+
const res = await libraAnswer(ctx.baseUrl, ctx.credOpts, lc.caseView.id, flags.question, st.text, flags.idempotencyKey || undefined, { fetchImpl: ctx.fetchImpl });
|
|
436
|
+
return io.ok({ case_id: lc.caseView.id, answer: res }, '回答已提交。追问只有一轮,到期未答视为放弃回答。');
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function describeRuling(r) {
|
|
440
|
+
const pts = (r.points || []).map((p, i) => ` ${i + 1}. ${p.title || p.key}:${p.conclusion}${p.reason ? `(${p.reason})` : ''}`);
|
|
441
|
+
return [
|
|
442
|
+
`第 ${r.round} 轮裁决书${r.final ? '(复核,终局)' : ''}`,
|
|
443
|
+
` 退还比例:${r.refund_pct}%`,
|
|
444
|
+
` 置信度:${r.confidence}`,
|
|
445
|
+
...(pts.length ? [' 逐个争议点:', ...pts] : []),
|
|
446
|
+
...(r.malice ? [` 明显恶意认定:${r.malice.party === 'buyer' ? '发起方' : r.malice.party === 'seller' ? '接单方' : '有'}(${r.malice.reason || '详见裁决书'})`] : []),
|
|
447
|
+
...((r.unreviewed || []).length ? [` 未能审阅:${r.unreviewed.length} 项`] : []),
|
|
448
|
+
...(r.fingerprint ? [` 指纹:${r.fingerprint}`] : []),
|
|
449
|
+
].join('\n');
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async function cmdRuling(ctx, io) {
|
|
453
|
+
const { flags } = ctx;
|
|
454
|
+
if (!flags.task) return io.fail(2, 'missing_task', '缺少 --task <task-id>');
|
|
455
|
+
if (flags.round != null && !['1', '2'].includes(String(flags.round))) return io.fail(2, 'bad_round', '--round 只能是 1(初裁)或 2(复核)');
|
|
456
|
+
const lc = await loadCase(ctx, io, flags.task);
|
|
457
|
+
if (lc.fail !== undefined) return lc.fail;
|
|
458
|
+
const res = await libraRulings(ctx.baseUrl, ctx.credOpts, lc.caseView.id, { fetchImpl: ctx.fetchImpl });
|
|
459
|
+
const all = Array.isArray(res) ? res : (res && res.rulings) || [];
|
|
460
|
+
const pick = flags.round != null ? all.filter((r) => String(r.round) === String(flags.round)) : all.slice(-1);
|
|
461
|
+
if (!pick.length) return io.fail(4, 'no_ruling', flags.round != null ? `还没有第 ${flags.round} 轮裁决书。` : '裁决书还没有出具,请在审理结束后再查看。');
|
|
462
|
+
return io.ok({ case_id: lc.caseView.id, rulings: pick }, pick.map(describeRuling).join('\n\n'));
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async function cmdAccept(ctx, io) {
|
|
466
|
+
if (!ctx.flags.task) return io.fail(2, 'missing_task', '缺少 --task <task-id>');
|
|
467
|
+
const lc = await loadCase(ctx, io, ctx.flags.task);
|
|
468
|
+
if (lc.fail !== undefined) return lc.fail;
|
|
469
|
+
const res = await libraAccept(ctx.baseUrl, ctx.credOpts, lc.caseView.id, ctx.flags.idempotencyKey || undefined, { fetchImpl: ctx.fetchImpl });
|
|
470
|
+
const text = res && res.execute
|
|
471
|
+
? '已接受裁决;双方都已接受,裁决立即执行。'
|
|
472
|
+
: '已接受裁决。对方尚未表态时,复核期满后会自动执行;你不再申请复核。';
|
|
473
|
+
return io.ok({ case_id: lc.caseView.id, result: res }, text);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async function cmdReviewQuote(ctx, io) {
|
|
477
|
+
const { flags } = ctx;
|
|
478
|
+
if (!flags.task) return io.fail(2, 'missing_task', '缺少 --task <task-id>');
|
|
479
|
+
const ev = evidenceInputs(flags, io);
|
|
480
|
+
if (ev.fail !== undefined) return ev.fail;
|
|
481
|
+
const lc = await loadCase(ctx, io, flags.task);
|
|
482
|
+
if (lc.fail !== undefined) return lc.fail;
|
|
483
|
+
const q = await libraReviewQuote(ctx.baseUrl, ctx.credOpts, lc.caseView.id, { evidence: ev.evidence }, { fetchImpl: ctx.fetchImpl });
|
|
484
|
+
saveQuote(ctx.credOpts, q.quote_id, {
|
|
485
|
+
kind: 'review', task_id: flags.task, case_id: lc.caseView.id, points: flags.points || null,
|
|
486
|
+
total_cents: q.total_cents, expires_at: q.expires_at,
|
|
487
|
+
});
|
|
488
|
+
const text = [
|
|
489
|
+
`复核报价(任务 ${flags.task})——本次不扣费`,
|
|
490
|
+
' 明细:', ...quoteLines(q),
|
|
491
|
+
` 合计:${yuan(q.total_cents)}(复核费 = 本轮材料 × 复核裁决员人数)`,
|
|
492
|
+
` 报价单:${q.quote_id}`,
|
|
493
|
+
` 有效至:${fmtTime(q.expires_at)}`,
|
|
494
|
+
'',
|
|
495
|
+
`申请复核需要用户本人在网页完成(Agent 不能提交),请把链接交给用户:${reviewWebUrl(ctx, lc.caseView.id, q.web_url)}`,
|
|
496
|
+
'', '须知:',
|
|
497
|
+
' · 复核费支付后不退还,与结果无关。',
|
|
498
|
+
' · 每个案件只有一次复核机会,复核裁决书即终局并立即执行。',
|
|
499
|
+
' · 对方会收到通知,并有 24 小时回应;先付费成功的一方才是申请方。',
|
|
500
|
+
].join('\n');
|
|
501
|
+
return io.ok({ quote: q, web_url: reviewWebUrl(ctx, lc.caseView.id, q.web_url) }, text);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// `libra review file` no longer submits anything either: applying for the review is a paid action for the person.
|
|
505
|
+
async function cmdReviewFile(ctx, io) {
|
|
506
|
+
const { flags } = ctx;
|
|
507
|
+
if (!flags.task) return io.fail(2, 'missing_task', '缺少 --task <task-id>');
|
|
508
|
+
const lc = await loadCase(ctx, io, flags.task);
|
|
509
|
+
if (lc.fail !== undefined) return lc.fail;
|
|
510
|
+
const known = flags.quote ? readCache(ctx.credOpts).quotes[flags.quote] : null;
|
|
511
|
+
const usable = known && known.kind === 'review' && known.task_id === flags.task
|
|
512
|
+
&& !(known.expires_at && new Date(known.expires_at).getTime() < Date.now());
|
|
513
|
+
const extra = usable ? { quote_id: flags.quote, total_cents: known.total_cents } : {};
|
|
514
|
+
return humanRequired(ctx, '申请复核', reviewWebUrl(ctx, lc.caseView.id), extra, [
|
|
515
|
+
...(usable ? [` 复核报价单 ${flags.quote} 合计 ${yuan(known.total_cents)}(本次未提交、未扣费)`] : [` 还没看过报价?先运行 stelfield libra review quote --task ${flags.task}(不扣费)`]),
|
|
516
|
+
' 每个案件只有一次复核机会,复核即终局;用户在网页确认并付款成功后才生效。',
|
|
517
|
+
]);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// ---------------------------------------------------------------- P19 early close
|
|
521
|
+
|
|
522
|
+
/** "30" / "30%" -> 30 ; anything else -> null. */
|
|
523
|
+
export function parsePct(text) {
|
|
524
|
+
if (text == null) return null;
|
|
525
|
+
const m = String(text).trim().match(/^(\d{1,3})%?$/);
|
|
526
|
+
if (!m) return null;
|
|
527
|
+
const n = Number(m[1]);
|
|
528
|
+
return n >= 1 && n <= 100 ? n : null;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** The case id (and, when it was looked up through the task, the case view). --case wins over --task. */
|
|
532
|
+
async function resolveCase(ctx, io) {
|
|
533
|
+
if (ctx.flags.caseId) return { caseId: ctx.flags.caseId, caseView: null };
|
|
534
|
+
if (!ctx.flags.task) return { fail: io.fail(2, 'missing_case', '缺少 --task <task-id>(或 --case <case-id>)') };
|
|
535
|
+
const lc = await loadCase(ctx, io, ctx.flags.task);
|
|
536
|
+
if (lc.fail !== undefined) return { fail: lc.fail };
|
|
537
|
+
return { caseId: lc.caseView.id, caseView: lc.caseView };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** The early_close block of the case view, fetching the case when only --case was given. */
|
|
541
|
+
async function earlyCloseOf(ctx, caseId, caseView) {
|
|
542
|
+
const view = caseView || await libraGetCase(ctx.baseUrl, ctx.credOpts, caseId, { fetchImpl: ctx.fetchImpl });
|
|
543
|
+
return (view && view.early_close) || null;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** What happens to the service fee, in one sentence (decision values: refund / keep / free). */
|
|
547
|
+
function feeSentence(decision) {
|
|
548
|
+
if (decision === 'refund') return '审理尚未开始,服务费原路退回。';
|
|
549
|
+
if (decision === 'free') return '本案系统送入,不收服务费。';
|
|
550
|
+
return '服务费不退还。';
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function closedText(res, head) {
|
|
554
|
+
const pct = res && res.pct != null ? `退还比例 ${res.pct}%。` : '';
|
|
555
|
+
const back = res && res.pct === 100 ? '全额退款:交付物将归还接单方,发起方之后不能再查看或下载。' : '';
|
|
556
|
+
return [head, ` ${pct}${feeSentence(res && res.fee_refund_decision)}`.trimEnd(),
|
|
557
|
+
...(back ? [` ${back}`] : []),
|
|
558
|
+
' 款项正在按结果处理;最终以 libra.closed_early 事件为准。'].join('\n');
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
async function cmdWithdraw(ctx, io) {
|
|
562
|
+
const rc = await resolveCase(ctx, io);
|
|
563
|
+
if (rc.fail !== undefined) return rc.fail;
|
|
564
|
+
// Sent on purpose: an Agent token is refused with 403 agent_token_forbidden + the page link (mapped to exit 5).
|
|
565
|
+
const res = await libraWithdrawCase(ctx.baseUrl, ctx.credOpts, rc.caseId, ctx.flags.idempotencyKey || undefined, { fetchImpl: ctx.fetchImpl });
|
|
566
|
+
return io.ok({ case_id: rc.caseId, result: res },
|
|
567
|
+
closedText(res, '已撤回裁决,视为确认验收,托管金额放款给接单方。'));
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
async function cmdConcede(ctx, io) {
|
|
571
|
+
const rc = await resolveCase(ctx, io);
|
|
572
|
+
if (rc.fail !== undefined) return rc.fail;
|
|
573
|
+
const pct = parsePct(ctx.flags.expectPct);
|
|
574
|
+
if (pct == null) {
|
|
575
|
+
const ec = await earlyCloseOf(ctx, rc.caseId, rc.caseView);
|
|
576
|
+
const seen = ec && ec.claim && ec.claim.pct != null ? `(对方现在主张退还 ${ec.claim.pct}%)` : '';
|
|
577
|
+
return io.fail(2, 'expect_pct_required',
|
|
578
|
+
`认可退款必须带 --expect-pct <你读到的退款比例>${seen}。先运行 stelfield libra status --task <task_id> 读到比例,再重新运行。`);
|
|
579
|
+
}
|
|
580
|
+
const res = await libraConcede(ctx.baseUrl, ctx.credOpts, rc.caseId, pct, ctx.flags.idempotencyKey || undefined, { fetchImpl: ctx.fetchImpl });
|
|
581
|
+
return io.ok({ case_id: rc.caseId, result: res }, closedText(res, `已认可退款 ${pct}%,案件提前结案。`));
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async function cmdSettlePropose(ctx, io) {
|
|
585
|
+
const rc = await resolveCase(ctx, io);
|
|
586
|
+
if (rc.fail !== undefined) return rc.fail;
|
|
587
|
+
const pct = parsePct(ctx.flags.pct);
|
|
588
|
+
if (pct == null) return io.fail(2, 'bad_pct', '缺少或写错 --pct <退还比例>,例如 --pct 30(区间见 stelfield libra status)');
|
|
589
|
+
// Pre-check against the range the platform shows for this case (never a number written into the CLI).
|
|
590
|
+
const ec = rc.caseView ? rc.caseView.early_close : null;
|
|
591
|
+
const range = ec && ec.settle_range;
|
|
592
|
+
if (range && (pct < range.min || pct > range.max || (pct - range.min) % Math.max(range.step, 1) !== 0)) {
|
|
593
|
+
return io.fail(2, 'invalid_settle_pct', `和解比例需在 ${range.min}%-${range.max}% 之间,每档 ${range.step}%(你填的是 ${pct}%)`);
|
|
594
|
+
}
|
|
595
|
+
const res = await libraProposeOffer(ctx.baseUrl, ctx.credOpts, rc.caseId, pct, ctx.flags.idempotencyKey || undefined, { fetchImpl: ctx.fetchImpl });
|
|
596
|
+
const offer = res && res.offer;
|
|
597
|
+
const text = [
|
|
598
|
+
`已向对方提出和解:退还发起方 ${pct}%${offer ? `(提议 #${offer.id})` : ''}。`,
|
|
599
|
+
' 对方接受即按此结案;拒绝则审理继续;裁决书出具前没有回应,提议随裁决书失效。',
|
|
600
|
+
' 同一时间只能有一个待回应的提议;想改比例,先 stelfield libra settle withdraw 撤回再重新提。',
|
|
601
|
+
].join('\n');
|
|
602
|
+
return io.ok({ case_id: rc.caseId, offer: offer || null }, text);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** The offer to act on: --offer <id>, else the pending one. Returns { offerId, pending } or { fail }. */
|
|
606
|
+
async function resolveOffer(ctx, io, rc) {
|
|
607
|
+
const ec = await earlyCloseOf(ctx, rc.caseId, rc.caseView);
|
|
608
|
+
const pending = ec && ec.pending_offer;
|
|
609
|
+
if (ctx.flags.offer) return { offerId: ctx.flags.offer, pending: pending && String(pending.id) === String(ctx.flags.offer) ? pending : null };
|
|
610
|
+
if (!pending) return { fail: io.fail(4, 'offer_not_found', '现在没有待回应的和解提议。') };
|
|
611
|
+
return { offerId: pending.id, pending };
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
async function cmdSettleAccept(ctx, io) {
|
|
615
|
+
const rc = await resolveCase(ctx, io);
|
|
616
|
+
if (rc.fail !== undefined) return rc.fail;
|
|
617
|
+
const ro = await resolveOffer(ctx, io, rc);
|
|
618
|
+
if (ro.fail !== undefined) return ro.fail;
|
|
619
|
+
const pct = parsePct(ctx.flags.expectPct);
|
|
620
|
+
if (pct == null) {
|
|
621
|
+
const seen = ro.pending ? `(当前待回应的提议:退还发起方 ${ro.pending.refund_pct}%)` : '';
|
|
622
|
+
return io.fail(2, 'expect_pct_required',
|
|
623
|
+
`接受和解必须带 --expect-pct <你读到的比例>${seen}。确认比例后重新运行:stelfield libra settle accept --case ${rc.caseId} --expect-pct <比例>`);
|
|
624
|
+
}
|
|
625
|
+
const res = await libraAcceptOffer(ctx.baseUrl, ctx.credOpts, rc.caseId, ro.offerId, pct, ctx.flags.idempotencyKey || undefined, { fetchImpl: ctx.fetchImpl });
|
|
626
|
+
return io.ok({ case_id: rc.caseId, offer_id: ro.offerId, result: res }, closedText(res, `已接受和解(退还 ${pct}%),案件提前结案。`));
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
async function cmdSettleReject(ctx, io) {
|
|
630
|
+
const rc = await resolveCase(ctx, io);
|
|
631
|
+
if (rc.fail !== undefined) return rc.fail;
|
|
632
|
+
const ro = await resolveOffer(ctx, io, rc);
|
|
633
|
+
if (ro.fail !== undefined) return ro.fail;
|
|
634
|
+
const res = await libraRejectOffer(ctx.baseUrl, ctx.credOpts, rc.caseId, ro.offerId, ctx.flags.idempotencyKey || undefined, { fetchImpl: ctx.fetchImpl });
|
|
635
|
+
return io.ok({ case_id: rc.caseId, offer_id: ro.offerId, result: res }, '已拒绝和解提议,审理继续。');
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
async function cmdSettleWithdraw(ctx, io) {
|
|
639
|
+
const rc = await resolveCase(ctx, io);
|
|
640
|
+
if (rc.fail !== undefined) return rc.fail;
|
|
641
|
+
const ro = await resolveOffer(ctx, io, rc);
|
|
642
|
+
if (ro.fail !== undefined) return ro.fail;
|
|
643
|
+
const res = await libraWithdrawOffer(ctx.baseUrl, ctx.credOpts, rc.caseId, ro.offerId, ctx.flags.idempotencyKey || undefined, { fetchImpl: ctx.fetchImpl });
|
|
644
|
+
return io.ok({ case_id: rc.caseId, offer_id: ro.offerId, result: res }, '已撤回你提出的和解提议。');
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const HANDLERS = {
|
|
648
|
+
'libra:quote': cmdQuote, 'libra:file': cmdFile, 'libra:status': cmdStatus, 'libra:statement': cmdStatement,
|
|
649
|
+
'libra:answer': cmdAnswer, 'libra:ruling': cmdRuling, 'libra:accept': cmdAccept,
|
|
650
|
+
'libra:review-quote': cmdReviewQuote, 'libra:review-file': cmdReviewFile,
|
|
651
|
+
// P19 early close
|
|
652
|
+
'libra:withdraw': cmdWithdraw, 'libra:concede': cmdConcede,
|
|
653
|
+
'libra:settle-propose': cmdSettlePropose, 'libra:settle-accept': cmdSettleAccept,
|
|
654
|
+
'libra:settle-reject': cmdSettleReject, 'libra:settle-withdraw': cmdSettleWithdraw,
|
|
655
|
+
};
|
|
656
|
+
|
|
657
|
+
// What the person has to do on the web, per command (used in the exit-5 notice).
|
|
658
|
+
const HUMAN_WHAT = { 'libra:withdraw': '撤回裁决' };
|
|
659
|
+
|
|
660
|
+
export function isLibraCommand(command) {
|
|
661
|
+
return Object.prototype.hasOwnProperty.call(HANDLERS, command);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Run one libra command. ctx: { command, flags, baseUrl, credOpts, fetchImpl, env, log, err }.
|
|
666
|
+
* Platform errors are mapped to the documented exit codes; upgrade-required errors are left to the caller.
|
|
667
|
+
*/
|
|
668
|
+
export async function runLibra(ctx) {
|
|
669
|
+
const io = makeIo(ctx);
|
|
670
|
+
try {
|
|
671
|
+
return await HANDLERS[ctx.command](ctx, io);
|
|
672
|
+
} catch (e) {
|
|
673
|
+
if (e && e.upgradeRequired) throw e;
|
|
674
|
+
const m = mapError(e);
|
|
675
|
+
if (m.human) {
|
|
676
|
+
const url = m.webUrl ? `${webBase(ctx)}${m.webUrl}` : null;
|
|
677
|
+
return humanRequired(ctx, HUMAN_WHAT[ctx.command] || '这个操作', url, { code: m.code });
|
|
678
|
+
}
|
|
679
|
+
return io.fail(m.exit, m.code, m.message);
|
|
680
|
+
}
|
|
681
|
+
}
|