@dalehkx/quote-cli 0.1.0 → 0.2.1
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/bin/quote.js +2 -0
- package/package.json +7 -2
- package/src/adapter/api.mjs +403 -38
- package/src/auth.mjs +312 -0
- package/src/commands/inquiry.mjs +137 -10
- package/src/commands/login.mjs +244 -0
- package/src/constants.mjs +11 -0
- package/src/store.mjs +6 -5
package/bin/quote.js
CHANGED
|
@@ -6,6 +6,7 @@ import { registerReplyCommands } from '../src/commands/reply.mjs';
|
|
|
6
6
|
import { registerCompareCommand } from '../src/commands/compare.mjs';
|
|
7
7
|
import { registerOrderCommands } from '../src/commands/order.mjs';
|
|
8
8
|
import { registerConfigCommands } from '../src/commands/config.mjs';
|
|
9
|
+
import { registerLoginCommands } from '../src/commands/login.mjs';
|
|
9
10
|
|
|
10
11
|
const program = new Command();
|
|
11
12
|
|
|
@@ -19,5 +20,6 @@ registerReplyCommands(program);
|
|
|
19
20
|
registerCompareCommand(program);
|
|
20
21
|
registerOrderCommands(program);
|
|
21
22
|
registerConfigCommands(program);
|
|
23
|
+
registerLoginCommands(program);
|
|
22
24
|
|
|
23
25
|
program.parse();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dalehkx/quote-cli",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "通用询报价 CLI 工具",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
"src/"
|
|
12
12
|
],
|
|
13
13
|
"scripts": {
|
|
14
|
-
"start": "node bin/quote.js"
|
|
14
|
+
"start": "node bin/quote.js",
|
|
15
|
+
"test": "vitest run",
|
|
16
|
+
"test:watch": "vitest"
|
|
15
17
|
},
|
|
16
18
|
"keywords": [
|
|
17
19
|
"quote",
|
|
@@ -27,6 +29,9 @@
|
|
|
27
29
|
"dependencies": {
|
|
28
30
|
"commander": "^12.1.0"
|
|
29
31
|
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"vitest": "^3.2.1"
|
|
34
|
+
},
|
|
30
35
|
"engines": {
|
|
31
36
|
"node": ">=18.0.0"
|
|
32
37
|
}
|
package/src/adapter/api.mjs
CHANGED
|
@@ -1,93 +1,458 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* API
|
|
3
|
-
*
|
|
2
|
+
* API 远程接口适配器
|
|
3
|
+
* 对接 terminal-api-v2 真实后端
|
|
4
4
|
*/
|
|
5
|
+
import { getValidToken } from '../auth.mjs';
|
|
6
|
+
import { APP_USER_AGENT } from '../constants.mjs';
|
|
7
|
+
import { Store } from '../store.mjs';
|
|
8
|
+
|
|
5
9
|
export class ApiAdapter {
|
|
6
10
|
constructor(config = {}) {
|
|
7
11
|
this.baseUrl = config.apiBase || '';
|
|
8
|
-
this.token = config.apiToken || '';
|
|
9
12
|
}
|
|
10
13
|
|
|
11
|
-
_headers() {
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
async _headers() {
|
|
15
|
+
const token = await getValidToken(this.baseUrl);
|
|
16
|
+
return {
|
|
17
|
+
'Content-Type': 'application/json',
|
|
18
|
+
'Authorization': `bearer ${token}`,
|
|
19
|
+
'User-Agent': APP_USER_AGENT,
|
|
20
|
+
};
|
|
15
21
|
}
|
|
16
22
|
|
|
17
|
-
async _request(method, path, body) {
|
|
23
|
+
async _request(method, path, body, query = {}) {
|
|
18
24
|
if (!this.baseUrl) {
|
|
19
25
|
throw new Error('API 模式未配置 apiBase,请执行: quote config set --mode api --api-base <url>');
|
|
20
26
|
}
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
if (!res.ok) {
|
|
27
|
-
const text = await res.text();
|
|
28
|
-
throw new Error(`API 请求失败 [${res.status}]: ${text}`);
|
|
27
|
+
|
|
28
|
+
const base = this.baseUrl.endsWith('/') ? this.baseUrl : `${this.baseUrl}/`;
|
|
29
|
+
const url = new URL(path.replace(/^\//, ''), base);
|
|
30
|
+
for (const [k, v] of Object.entries(query)) {
|
|
31
|
+
if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, v);
|
|
29
32
|
}
|
|
30
|
-
|
|
33
|
+
|
|
34
|
+
const opts = { method, headers: await this._headers() };
|
|
35
|
+
if (body && method !== 'GET') opts.body = JSON.stringify(body);
|
|
36
|
+
|
|
37
|
+
const res = await fetch(url.toString(), opts);
|
|
38
|
+
const json = await res.json();
|
|
39
|
+
|
|
40
|
+
if (json.errorCode !== undefined && json.errorCode !== 0) {
|
|
41
|
+
const err = new Error(json.message || `API 错误 [${json.errorCode}]`);
|
|
42
|
+
err.errorCode = json.errorCode;
|
|
43
|
+
throw err;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return json.data !== undefined ? json.data : json;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
_getStore() {
|
|
50
|
+
return new Store();
|
|
31
51
|
}
|
|
32
52
|
|
|
33
53
|
// ─── Inquiry ─────────────────────────────────────────────
|
|
34
54
|
|
|
55
|
+
/**
|
|
56
|
+
* 获取支持的品牌列表
|
|
57
|
+
* carBrandCode 即为 carBrandId
|
|
58
|
+
*/
|
|
59
|
+
async listSupportBrands() {
|
|
60
|
+
try {
|
|
61
|
+
const result = await this._request('GET', '/inquiries/support_brands');
|
|
62
|
+
return Array.isArray(result) ? result : [];
|
|
63
|
+
} catch {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 根据 VIN 获取车型品牌信息
|
|
70
|
+
*/
|
|
71
|
+
async getCarModelByVin(vin) {
|
|
72
|
+
try {
|
|
73
|
+
const result = await this._request('GET', '/inquiries/car_models', null, { vin });
|
|
74
|
+
const model = Array.isArray(result) ? result[0] : null;
|
|
75
|
+
if (!model) return null;
|
|
76
|
+
// carBrandId 可能为空,用 carBrandCode 兜底
|
|
77
|
+
return {
|
|
78
|
+
...model,
|
|
79
|
+
carBrandId: model.carBrandId || model.carBrandCode || '',
|
|
80
|
+
carModelName: model.model || model.epcModelName || model.saleModelName || '',
|
|
81
|
+
};
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 创建询价
|
|
89
|
+
* 优先走 simple_inquiry(自由文本,无需 VIN 和手机号)
|
|
90
|
+
* 若账号不支持(654)则自动降级到 POST /inquiries
|
|
91
|
+
*/
|
|
35
92
|
async createInquiry(data) {
|
|
36
|
-
|
|
93
|
+
const config = this._getStore().getConfig();
|
|
94
|
+
|
|
95
|
+
let carBrandId = data.carBrandId || '';
|
|
96
|
+
let carBrandName = data.carBrandName || data.vehicle || '';
|
|
97
|
+
let carModelName = data.carModelName || data.vehicle || '';
|
|
98
|
+
|
|
99
|
+
if (data.vin && !carBrandId) {
|
|
100
|
+
const model = await this.getCarModelByVin(data.vin);
|
|
101
|
+
if (model) {
|
|
102
|
+
carBrandId = model.carBrandId || carBrandId;
|
|
103
|
+
carBrandName = model.carBrandName || carBrandName;
|
|
104
|
+
carModelName = model.carModelName || carModelName;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 支持多配件
|
|
109
|
+
const products = data.products && data.products.length > 0
|
|
110
|
+
? data.products
|
|
111
|
+
: [data.product];
|
|
112
|
+
|
|
113
|
+
const simpleBody = {
|
|
114
|
+
vin: data.vin || '',
|
|
115
|
+
carBrandId,
|
|
116
|
+
carBrandName,
|
|
117
|
+
carModelName,
|
|
118
|
+
userName: config.userLoginId || '',
|
|
119
|
+
source: 'ANDROID',
|
|
120
|
+
qualities: data.qualities || ['BRAND'],
|
|
121
|
+
isOpenInvoice: false,
|
|
122
|
+
isAnonymous: false,
|
|
123
|
+
provinceGeoId: config.provinceGeoId || '',
|
|
124
|
+
cityGeoId: config.cityGeoId || '',
|
|
125
|
+
countyGeoId: config.countyGeoId || '',
|
|
126
|
+
provinceGeoName: config.provinceGeoName || '',
|
|
127
|
+
cityGeoName: config.cityGeoName || '',
|
|
128
|
+
countyGeoName: config.countyGeoName || '',
|
|
129
|
+
garageCompanyName: config.companyName || '',
|
|
130
|
+
// 多配件:每个 product 一个 item
|
|
131
|
+
simpleInquiryBatchItems: products.map((name, i) => ({
|
|
132
|
+
content: name,
|
|
133
|
+
mediaType: 'TEXT',
|
|
134
|
+
itemNum: i + 1,
|
|
135
|
+
description: i === 0 ? [
|
|
136
|
+
data.oeNumber ? `OE: ${data.oeNumber}` : '',
|
|
137
|
+
data.note || '',
|
|
138
|
+
].filter(Boolean).join(' ') : '',
|
|
139
|
+
})),
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
let result;
|
|
143
|
+
try {
|
|
144
|
+
result = await this._request('POST', '/inquiries/simple_inquiry', simpleBody);
|
|
145
|
+
} catch (e) {
|
|
146
|
+
// 654 = 未认证不允许简易询价
|
|
147
|
+
// 999 = beta 环境 simple_inquiry 服务不稳定
|
|
148
|
+
// 两种情况均降级到 POST /inquiries
|
|
149
|
+
if (e.errorCode === 654 || e.errorCode === 999) {
|
|
150
|
+
// 先检查剩余次数,给出明确错误
|
|
151
|
+
try {
|
|
152
|
+
const quota = await this._request('GET', '/inquiries/remain_inquiry_number');
|
|
153
|
+
if (quota && quota.inquiryRemainNumber === 0) {
|
|
154
|
+
throw new Error('询价次数已用完,请完成企业认证后继续使用: https://ec-hwbeta.casstime.com');
|
|
155
|
+
}
|
|
156
|
+
} catch (qe) {
|
|
157
|
+
if (qe.message.includes('询价次数')) throw qe;
|
|
158
|
+
}
|
|
159
|
+
const fullBody = {
|
|
160
|
+
vin: data.vin || 'UNKNOWN00000000000',
|
|
161
|
+
carBrandId,
|
|
162
|
+
carBrandName,
|
|
163
|
+
carModelName,
|
|
164
|
+
userName: config.userLoginId || '',
|
|
165
|
+
contactNumber: config.cellphone || '',
|
|
166
|
+
isOpenInvoice: false,
|
|
167
|
+
source: 'ANDROID',
|
|
168
|
+
isSelectBrandFlag: false,
|
|
169
|
+
isAnonymous: false,
|
|
170
|
+
qualities: data.qualities || ['BRAND'],
|
|
171
|
+
provinceGeoId: config.provinceGeoId || '',
|
|
172
|
+
cityGeoId: config.cityGeoId || '',
|
|
173
|
+
countyGeoId: config.countyGeoId || '',
|
|
174
|
+
provinceGeoName: config.provinceGeoName || '',
|
|
175
|
+
cityGeoName: config.cityGeoName || '',
|
|
176
|
+
countyGeoName: config.countyGeoName || '',
|
|
177
|
+
userNeeds: products.map((name, i) => ({
|
|
178
|
+
needsName: name,
|
|
179
|
+
quantity: i === 0 ? (Number(data.quantity) || 1) : 1,
|
|
180
|
+
isFastOe: false,
|
|
181
|
+
isSuggest: false,
|
|
182
|
+
imageUrls: [],
|
|
183
|
+
originalNeed: name,
|
|
184
|
+
inquirySource: 'MANUALLY',
|
|
185
|
+
oeCode: i === 0 ? (data.oeNumber || '') : '',
|
|
186
|
+
remark: i === 0 ? (data.note || '') : '',
|
|
187
|
+
})),
|
|
188
|
+
};
|
|
189
|
+
result = await this._request('POST', '/inquiries', fullBody);
|
|
190
|
+
} else {
|
|
191
|
+
throw e;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
id: result.inquiryId || result,
|
|
197
|
+
product: products.join('、'),
|
|
198
|
+
oeNumber: data.oeNumber || '',
|
|
199
|
+
vehicle: carBrandName + (carModelName ? ` ${carModelName}` : ''),
|
|
200
|
+
quantity: Number(data.quantity) || 1,
|
|
201
|
+
note: data.note || '',
|
|
202
|
+
status: 'pending',
|
|
203
|
+
createdAt: new Date().toISOString(),
|
|
204
|
+
};
|
|
37
205
|
}
|
|
38
206
|
|
|
207
|
+
/**
|
|
208
|
+
* 获取询价单列表
|
|
209
|
+
*/
|
|
39
210
|
async listInquiries(filter = {}) {
|
|
40
|
-
const
|
|
41
|
-
|
|
211
|
+
const statusMap = {
|
|
212
|
+
pending: ['UNQUOTE', 'WAIT_QUOTATION', 'QUOTING'],
|
|
213
|
+
quoted: ['QUOTE', 'QUOTED', 'PART_QUOTED'],
|
|
214
|
+
ordered: ['ORDERED'],
|
|
215
|
+
closed: ['IS_CLOSED', 'CLOSED', 'EXPIRED', 'ABATE'],
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const body = {};
|
|
219
|
+
if (filter.status && statusMap[filter.status]) {
|
|
220
|
+
body.statusIds = statusMap[filter.status];
|
|
221
|
+
}
|
|
222
|
+
if (filter.keyword) {
|
|
223
|
+
body.searchContext = filter.keyword;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const result = await this._request('POST', '/inquiries/list', body, {
|
|
227
|
+
page: filter.page || 1,
|
|
228
|
+
size: filter.size || 20,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const items = result.content || result || [];
|
|
232
|
+
return Array.isArray(items) ? items.map(this._mapInquiryItem) : [];
|
|
42
233
|
}
|
|
43
234
|
|
|
235
|
+
/**
|
|
236
|
+
* 获取询价单详情
|
|
237
|
+
*/
|
|
44
238
|
async getInquiry(id) {
|
|
45
|
-
|
|
239
|
+
const result = await this._request('GET', `/inquiries/${id}/detailV2`, null, {
|
|
240
|
+
platform: 'ANDROID',
|
|
241
|
+
});
|
|
242
|
+
return this._mapInquiryDetail(id, result);
|
|
46
243
|
}
|
|
47
244
|
|
|
245
|
+
/**
|
|
246
|
+
* 关闭询价单
|
|
247
|
+
*/
|
|
48
248
|
async closeInquiry(id) {
|
|
49
|
-
|
|
249
|
+
try {
|
|
250
|
+
await this._request('POST', '/inquiries/status', { inquiryId: id, status: 'CLOSED' });
|
|
251
|
+
return { id, status: 'closed' };
|
|
252
|
+
} catch (e) {
|
|
253
|
+
throw new Error(`关闭询价单失败: ${e.message}`);
|
|
254
|
+
}
|
|
50
255
|
}
|
|
51
256
|
|
|
52
|
-
// ─── Reply
|
|
257
|
+
// ─── Reply(报价结果)────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* 获取某询价单的报价列表
|
|
261
|
+
* 先从询价单详情里取已报价的 storeId,再逐一查报价
|
|
262
|
+
*/
|
|
263
|
+
async listReplies(inquiryId) {
|
|
264
|
+
try {
|
|
265
|
+
// 从详情里取已报价的供应商列表
|
|
266
|
+
const detail = await this._request('GET', `/inquiries/${inquiryId}/detailV2`, null, {
|
|
267
|
+
platform: 'ANDROID',
|
|
268
|
+
});
|
|
53
269
|
|
|
54
|
-
|
|
55
|
-
|
|
270
|
+
const stores = detail.inquiryQuoteStores || [];
|
|
271
|
+
if (stores.length === 0) return [];
|
|
272
|
+
|
|
273
|
+
// 逐一查每家供应商的报价
|
|
274
|
+
const results = await Promise.all(
|
|
275
|
+
stores.map(async (store) => {
|
|
276
|
+
try {
|
|
277
|
+
const result = await this._request('GET', '/inquiries/store/quotation', null, {
|
|
278
|
+
inquiryId,
|
|
279
|
+
storeId: store.storeId,
|
|
280
|
+
});
|
|
281
|
+
return (result.consultingQuotationProducts || []).map(this._mapQuotationProduct);
|
|
282
|
+
} catch {
|
|
283
|
+
return [];
|
|
284
|
+
}
|
|
285
|
+
})
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
return results.flat();
|
|
289
|
+
} catch {
|
|
290
|
+
return [];
|
|
291
|
+
}
|
|
56
292
|
}
|
|
57
293
|
|
|
58
|
-
|
|
59
|
-
|
|
294
|
+
/**
|
|
295
|
+
* 创建报价 — 平台模式下买方不手动创建报价,由供应商在平台上操作
|
|
296
|
+
*/
|
|
297
|
+
async createReply(_data) {
|
|
298
|
+
throw new Error('API 模式下报价由供应商在平台上提交,买方无需手动创建');
|
|
60
299
|
}
|
|
61
300
|
|
|
62
301
|
// ─── Compare ─────────────────────────────────────────────
|
|
63
302
|
|
|
303
|
+
/**
|
|
304
|
+
* 比价 — 获取报价并排序
|
|
305
|
+
*/
|
|
64
306
|
async compareReplies(inquiryId, sort = 'price') {
|
|
65
|
-
|
|
307
|
+
const inquiry = await this.getInquiry(inquiryId);
|
|
308
|
+
const replies = await this.listReplies(inquiryId);
|
|
309
|
+
|
|
310
|
+
if (!inquiry) return null;
|
|
311
|
+
|
|
312
|
+
const sorted = [...replies].sort((a, b) => {
|
|
313
|
+
if (sort === 'price') return a.price - b.price;
|
|
314
|
+
if (sort === 'delivery') return (a.delivery || 999) - (b.delivery || 999);
|
|
315
|
+
if (sort === 'brand') return (a.brand || '').localeCompare(b.brand || '');
|
|
316
|
+
return 0;
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
const lowest = sorted[0] || null;
|
|
320
|
+
const fastest = [...replies].sort((a, b) => (a.delivery || 999) - (b.delivery || 999))[0] || null;
|
|
321
|
+
|
|
322
|
+
return { inquiry, sorted, lowest, fastest };
|
|
66
323
|
}
|
|
67
324
|
|
|
68
325
|
// ─── Order ───────────────────────────────────────────────
|
|
69
326
|
|
|
327
|
+
/**
|
|
328
|
+
* 确认下单(采购确认)
|
|
329
|
+
*/
|
|
70
330
|
async confirmOrder(inquiryId, replyId) {
|
|
71
|
-
|
|
331
|
+
await this._request('POST', '/inquiries/purchase_confirm', {
|
|
332
|
+
inquiryId,
|
|
333
|
+
quotationProductIds: [replyId],
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
const inquiry = await this.getInquiry(inquiryId);
|
|
337
|
+
return {
|
|
338
|
+
order: {
|
|
339
|
+
id: `ORD-${Date.now()}`,
|
|
340
|
+
inquiryId,
|
|
341
|
+
replyId,
|
|
342
|
+
status: 'confirmed',
|
|
343
|
+
confirmedAt: new Date().toISOString(),
|
|
344
|
+
},
|
|
345
|
+
inquiry,
|
|
346
|
+
reply: { id: replyId, supplier: '平台供应商', price: 0 },
|
|
347
|
+
};
|
|
72
348
|
}
|
|
73
349
|
|
|
74
350
|
async listOrders() {
|
|
75
|
-
|
|
351
|
+
try {
|
|
352
|
+
const result = await this._request('POST', '/inquiries/order/list', {}, {
|
|
353
|
+
page: 1,
|
|
354
|
+
size: 20,
|
|
355
|
+
});
|
|
356
|
+
const items = result.content || result || [];
|
|
357
|
+
return Array.isArray(items) ? items.map(this._mapOrderItem) : [];
|
|
358
|
+
} catch {
|
|
359
|
+
return [];
|
|
360
|
+
}
|
|
76
361
|
}
|
|
77
362
|
|
|
78
363
|
// ─── Config ──────────────────────────────────────────────
|
|
79
|
-
// config 始终本地存储(包含 apiBase 等连接信息)
|
|
80
364
|
|
|
81
365
|
async getConfig() {
|
|
82
|
-
|
|
83
|
-
const { Store } = await import('../store.mjs');
|
|
84
|
-
const store = new Store();
|
|
85
|
-
return store.getConfig();
|
|
366
|
+
return this._getStore().getConfig();
|
|
86
367
|
}
|
|
87
368
|
|
|
88
369
|
async setConfig(updates) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
370
|
+
return this._getStore().setConfig(updates);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ─── 数据映射 ────────────────────────────────────────────
|
|
374
|
+
|
|
375
|
+
_mapInquiryItem(item) {
|
|
376
|
+
return {
|
|
377
|
+
id: item.inquiryId || item.id || '',
|
|
378
|
+
product: item.userNeed || '询价单',
|
|
379
|
+
vehicle: item.carModelName || item.saleModelName || '',
|
|
380
|
+
quantity: 1,
|
|
381
|
+
status: mapStatus(item.statusId || ''),
|
|
382
|
+
createdAt: item.createdStamp ? new Date(item.createdStamp).toISOString() : '',
|
|
383
|
+
};
|
|
92
384
|
}
|
|
385
|
+
|
|
386
|
+
_mapInquiryDetail(id, detail) {
|
|
387
|
+
// needs 是配件需求数组,取第一条作为主产品名
|
|
388
|
+
const needs = detail.needs || [];
|
|
389
|
+
const firstNeed = needs[0] || {};
|
|
390
|
+
return {
|
|
391
|
+
id,
|
|
392
|
+
product: firstNeed.needsName || detail.userNeed || '询价单',
|
|
393
|
+
oeNumber: firstNeed.oeResults?.[0]?.oeCode || '',
|
|
394
|
+
vehicle: detail.carModelName || detail.saleModelName || '',
|
|
395
|
+
vin: detail.vin || '',
|
|
396
|
+
quantity: firstNeed.quantity || 1,
|
|
397
|
+
status: mapStatus(detail.statusId || ''),
|
|
398
|
+
statusDesc: detail.statusDesc || '',
|
|
399
|
+
createdAt: detail.createdStamp ? new Date(detail.createdStamp).toISOString() : '',
|
|
400
|
+
carBrand: detail.carBrandName || '',
|
|
401
|
+
needs: needs.map(n => ({
|
|
402
|
+
id: n.needId || '',
|
|
403
|
+
name: n.needsName || '',
|
|
404
|
+
quantity: n.quantity || 1,
|
|
405
|
+
remark: n.remark || '',
|
|
406
|
+
status: n.statusDesc || '',
|
|
407
|
+
})),
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
_mapQuotationProduct(item) {
|
|
412
|
+
return {
|
|
413
|
+
id: item.quotationProductId || '',
|
|
414
|
+
supplier: item.displayName || item.brandName || '',
|
|
415
|
+
price: parseFloat(item.displayPrice) || 0,
|
|
416
|
+
currency: 'CNY',
|
|
417
|
+
brand: item.brandName || '',
|
|
418
|
+
partNum: item.partsNum || '',
|
|
419
|
+
delivery: item.arrivalTime || null,
|
|
420
|
+
note: item.remark || '',
|
|
421
|
+
quality: item.qualityDescription || '',
|
|
422
|
+
location: item.locationName || '',
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
_mapOrderItem(item) {
|
|
427
|
+
return {
|
|
428
|
+
id: item.orderId || item.id || '',
|
|
429
|
+
inquiryId: item.inquiryId || '',
|
|
430
|
+
status: item.statusId || 'confirmed',
|
|
431
|
+
confirmedAt: item.createdStamp ? new Date(item.createdStamp).toISOString() : '',
|
|
432
|
+
inquiry: { product: item.productName || '?' },
|
|
433
|
+
reply: { supplier: item.storeName || '?', price: item.totalPrice || 0, currency: 'CNY' },
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ─── 状态映射 ──────────────────────────────────────────────
|
|
439
|
+
|
|
440
|
+
function mapStatus(platformStatus) {
|
|
441
|
+
const map = {
|
|
442
|
+
UNQUOTE: 'pending',
|
|
443
|
+
WAIT_QUOTATION: 'pending',
|
|
444
|
+
QUOTING: 'pending',
|
|
445
|
+
IN_THE_DECODING: 'pending',
|
|
446
|
+
DECODED: 'pending',
|
|
447
|
+
QUOTE: 'quoted',
|
|
448
|
+
QUOTED: 'quoted',
|
|
449
|
+
PART_QUOTED: 'quoted',
|
|
450
|
+
ORDERED: 'ordered',
|
|
451
|
+
IS_CLOSED: 'closed',
|
|
452
|
+
CLOSED: 'closed',
|
|
453
|
+
EXPIRED: 'closed',
|
|
454
|
+
ABATE: 'closed',
|
|
455
|
+
CANCELED: 'closed',
|
|
456
|
+
};
|
|
457
|
+
return map[platformStatus] || platformStatus || 'pending';
|
|
93
458
|
}
|
package/src/auth.mjs
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 认证模块 — 处理登录、token 存储与自动续期
|
|
3
|
+
*/
|
|
4
|
+
import { Store } from './store.mjs';
|
|
5
|
+
import { APP_USER_AGENT, API_BASE_DEFAULT } from './constants.mjs';
|
|
6
|
+
|
|
7
|
+
function getStore() {
|
|
8
|
+
return new Store();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 用户名密码登录
|
|
13
|
+
*/
|
|
14
|
+
export async function loginWithPassword(apiBase, userLoginName, password) {
|
|
15
|
+
const url = `${apiBase}/public/auth/ecapp/login/password`;
|
|
16
|
+
const res = await fetch(url, {
|
|
17
|
+
method: 'POST',
|
|
18
|
+
headers: {
|
|
19
|
+
'Content-Type': 'application/json',
|
|
20
|
+
'User-Agent': APP_USER_AGENT,
|
|
21
|
+
},
|
|
22
|
+
body: JSON.stringify({ userLoginName, password }),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const json = await res.json();
|
|
26
|
+
if (json.errorCode !== 0 && !json.data?.accessToken) {
|
|
27
|
+
throw new Error(json.message || `登录失败 [errorCode: ${json.errorCode}]`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const tokenData = json.data || json;
|
|
31
|
+
saveTokens(tokenData);
|
|
32
|
+
await fetchAndCacheUserInfo(apiBase);
|
|
33
|
+
return tokenData;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 查询地区列表(根据父节点 geoId)
|
|
38
|
+
* 根节点: CHN → 省 → 市 → 区县
|
|
39
|
+
*/
|
|
40
|
+
export async function fetchAreas(apiBase, geoId) {
|
|
41
|
+
const config = getStore().getConfig();
|
|
42
|
+
const url = new URL(`${apiBase}/public/area`);
|
|
43
|
+
url.searchParams.set('geoId', geoId);
|
|
44
|
+
|
|
45
|
+
const res = await fetch(url.toString(), {
|
|
46
|
+
headers: {
|
|
47
|
+
'Authorization': `bearer ${config.accessToken}`,
|
|
48
|
+
'User-Agent': APP_USER_AGENT,
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const json = await res.json();
|
|
53
|
+
if (json.errorCode !== 0) {
|
|
54
|
+
throw new Error(json.message || '查询地区失败');
|
|
55
|
+
}
|
|
56
|
+
return json.data || [];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 完善公司信息(注册后调用,设置 registerCompleted=true)
|
|
61
|
+
*/
|
|
62
|
+
export async function saveCompanyInfo(apiBase, { companyName, provinceId, provinceName, cityId, cityName, countyId, countyName, address }) {
|
|
63
|
+
const config = getStore().getConfig();
|
|
64
|
+
const res = await fetch(`${apiBase}/users/save_company_info`, {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
headers: {
|
|
67
|
+
'Content-Type': 'application/json',
|
|
68
|
+
'Authorization': `bearer ${config.accessToken}`,
|
|
69
|
+
'User-Agent': APP_USER_AGENT,
|
|
70
|
+
},
|
|
71
|
+
body: JSON.stringify({
|
|
72
|
+
companyName,
|
|
73
|
+
provinceId,
|
|
74
|
+
provinceName,
|
|
75
|
+
cityId,
|
|
76
|
+
cityName,
|
|
77
|
+
countyId,
|
|
78
|
+
countyName,
|
|
79
|
+
address: address || '',
|
|
80
|
+
isCustomerManagerValidated: false,
|
|
81
|
+
}),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const json = await res.json();
|
|
85
|
+
if (json.errorCode !== 0) {
|
|
86
|
+
throw new Error(json.message || `完善公司信息失败 [errorCode: ${json.errorCode}]`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 更新本地缓存
|
|
90
|
+
await fetchAndCacheUserInfo(apiBase);
|
|
91
|
+
return json.data;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 发送注册验证码
|
|
96
|
+
*/
|
|
97
|
+
export async function sendRegisterCode(apiBase, cellphone) {
|
|
98
|
+
const url = new URL(`${apiBase}/public/verify_code`);
|
|
99
|
+
url.searchParams.set('channelId', 'REGISTER');
|
|
100
|
+
url.searchParams.set('cellphone', cellphone);
|
|
101
|
+
|
|
102
|
+
const res = await fetch(url.toString(), {
|
|
103
|
+
method: 'GET',
|
|
104
|
+
headers: { 'User-Agent': APP_USER_AGENT },
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const json = await res.json();
|
|
108
|
+
if (json.errorCode !== 0) {
|
|
109
|
+
throw new Error(json.message || `发送验证码失败 [errorCode: ${json.errorCode}]`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 注册新用户
|
|
115
|
+
*/
|
|
116
|
+
export async function registerUser(apiBase, { cellphone, password, username, verificationCode }) {
|
|
117
|
+
const res = await fetch(`${apiBase}/public/users/register`, {
|
|
118
|
+
method: 'POST',
|
|
119
|
+
headers: {
|
|
120
|
+
'Content-Type': 'application/json',
|
|
121
|
+
'User-Agent': APP_USER_AGENT,
|
|
122
|
+
},
|
|
123
|
+
body: JSON.stringify({
|
|
124
|
+
cellphone,
|
|
125
|
+
password,
|
|
126
|
+
username,
|
|
127
|
+
verificationCode,
|
|
128
|
+
registerSource: 'ANDROID',
|
|
129
|
+
}),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const json = await res.json();
|
|
133
|
+
if (json.errorCode !== 0) {
|
|
134
|
+
throw new Error(json.message || `注册失败 [errorCode: ${json.errorCode}]`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return json.data || json;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* 检查账号是否已注册
|
|
142
|
+
* 返回 true 表示已注册,false 表示未注册
|
|
143
|
+
*/
|
|
144
|
+
export async function checkAccountExists(apiBase, account) {
|
|
145
|
+
const res = await fetch(`${apiBase}/public/users/${encodeURIComponent(account)}/account_info`, {
|
|
146
|
+
headers: { 'User-Agent': APP_USER_AGENT },
|
|
147
|
+
});
|
|
148
|
+
const json = await res.json();
|
|
149
|
+
// 702 = 账号不存在
|
|
150
|
+
return json.errorCode !== 702;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 发送登录验证码
|
|
155
|
+
*/
|
|
156
|
+
export async function sendLoginCode(apiBase, cellphone) {
|
|
157
|
+
const url = new URL(`${apiBase}/public/verify_code`);
|
|
158
|
+
url.searchParams.set('channelId', 'LOGIN');
|
|
159
|
+
url.searchParams.set('cellphone', cellphone);
|
|
160
|
+
|
|
161
|
+
const res = await fetch(url.toString(), {
|
|
162
|
+
method: 'GET',
|
|
163
|
+
headers: {
|
|
164
|
+
'Content-Type': 'application/json',
|
|
165
|
+
'User-Agent': APP_USER_AGENT,
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const json = await res.json();
|
|
170
|
+
if (json.errorCode !== 0) {
|
|
171
|
+
throw new Error(json.message || `发送验证码失败 [errorCode: ${json.errorCode}]`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* 手机号验证码登录
|
|
177
|
+
*/
|
|
178
|
+
export async function loginWithCellphone(apiBase, cellphone, verifyCode) {
|
|
179
|
+
const url = `${apiBase}/public/auth/ecapp/login/cellphone`;
|
|
180
|
+
const res = await fetch(url, {
|
|
181
|
+
method: 'POST',
|
|
182
|
+
headers: {
|
|
183
|
+
'Content-Type': 'application/json',
|
|
184
|
+
'User-Agent': APP_USER_AGENT,
|
|
185
|
+
},
|
|
186
|
+
body: JSON.stringify({ cellphone, verifyCode }),
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const json = await res.json();
|
|
190
|
+
if (json.errorCode !== 0 && !json.data?.accessToken) {
|
|
191
|
+
throw new Error(json.message || `登录失败 [errorCode: ${json.errorCode}]`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const tokenData = json.data || json;
|
|
195
|
+
saveTokens(tokenData);
|
|
196
|
+
await fetchAndCacheUserInfo(apiBase);
|
|
197
|
+
return tokenData;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* 刷新 token
|
|
202
|
+
*/
|
|
203
|
+
export async function refreshToken(apiBase) {
|
|
204
|
+
const config = getStore().getConfig();
|
|
205
|
+
if (!config.refreshToken) {
|
|
206
|
+
throw new Error('无 refreshToken,请重新登录: quote login');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const url = `${apiBase}/public/auth/ecapp/refresh_token`;
|
|
210
|
+
const res = await fetch(url, {
|
|
211
|
+
method: 'POST',
|
|
212
|
+
headers: {
|
|
213
|
+
'Content-Type': 'application/json',
|
|
214
|
+
'User-Agent': APP_USER_AGENT,
|
|
215
|
+
},
|
|
216
|
+
body: JSON.stringify({
|
|
217
|
+
refreshToken: config.refreshToken,
|
|
218
|
+
clientId: 'CASSAPP',
|
|
219
|
+
}),
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const json = await res.json();
|
|
223
|
+
if (json.errorCode !== 0 && !json.data?.accessToken) {
|
|
224
|
+
throw new Error(json.message || 'Token 续期失败,请重新登录: quote login');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const tokenData = json.data || json;
|
|
228
|
+
saveTokens(tokenData);
|
|
229
|
+
return tokenData;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* 获取有效的 accessToken(过期自动续期)
|
|
234
|
+
*/
|
|
235
|
+
export async function getValidToken(apiBase) {
|
|
236
|
+
const config = getStore().getConfig();
|
|
237
|
+
|
|
238
|
+
if (!config.accessToken) {
|
|
239
|
+
throw new Error('未登录,请先执行: quote login');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// 提前 60 秒判定过期
|
|
243
|
+
const now = Date.now();
|
|
244
|
+
if (config.tokenExpiresAt && now > config.tokenExpiresAt - 60_000) {
|
|
245
|
+
const tokenData = await refreshToken(apiBase);
|
|
246
|
+
return tokenData.accessToken;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return config.accessToken;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* 检查是否已登录
|
|
254
|
+
*/
|
|
255
|
+
export function isLoggedIn() {
|
|
256
|
+
const config = getStore().getConfig();
|
|
257
|
+
return !!(config.accessToken && config.refreshToken);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* 登出(清除 token)
|
|
262
|
+
*/
|
|
263
|
+
export function logout() {
|
|
264
|
+
getStore().setConfig({
|
|
265
|
+
accessToken: undefined,
|
|
266
|
+
refreshToken: undefined,
|
|
267
|
+
tokenType: undefined,
|
|
268
|
+
tokenExpiresAt: undefined,
|
|
269
|
+
userLoginId: undefined,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ─── 内部 ──────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
async function fetchAndCacheUserInfo(apiBase) {
|
|
276
|
+
try {
|
|
277
|
+
const config = getStore().getConfig();
|
|
278
|
+
const res = await fetch(`${apiBase}/users/_current`, {
|
|
279
|
+
headers: {
|
|
280
|
+
'Authorization': `bearer ${config.accessToken}`,
|
|
281
|
+
'User-Agent': APP_USER_AGENT,
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
const json = await res.json();
|
|
285
|
+
if (json.errorCode !== 0) return;
|
|
286
|
+
const u = json.data || json;
|
|
287
|
+
getStore().setConfig({
|
|
288
|
+
companyName: u.companyName || u.displayName || '',
|
|
289
|
+
garageCompanyId: u.garageCompanyId || '',
|
|
290
|
+
cellphone: u.cellphone || '',
|
|
291
|
+
provinceGeoId: u.provinceGeoId || '',
|
|
292
|
+
cityGeoId: u.cityGeoId || '',
|
|
293
|
+
countyGeoId: u.countyGeoId || '',
|
|
294
|
+
provinceGeoName: u.provinceGeoName || '',
|
|
295
|
+
cityGeoName: u.cityGeoName || '',
|
|
296
|
+
countyGeoName: u.countyGeoName || '',
|
|
297
|
+
});
|
|
298
|
+
} catch {
|
|
299
|
+
// 拉取失败不影响登录流程
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function saveTokens(tokenData) {
|
|
304
|
+
const expiresAt = Date.now() + (tokenData.expiresIn || 7200) * 1000;
|
|
305
|
+
getStore().setConfig({
|
|
306
|
+
accessToken: tokenData.accessToken,
|
|
307
|
+
refreshToken: tokenData.refreshToken,
|
|
308
|
+
tokenType: tokenData.tokenType || 'bearer',
|
|
309
|
+
tokenExpiresAt: expiresAt,
|
|
310
|
+
userLoginId: tokenData.userLoginId || '',
|
|
311
|
+
});
|
|
312
|
+
}
|
package/src/commands/inquiry.mjs
CHANGED
|
@@ -6,22 +6,76 @@ export function registerInquiryCommands(program) {
|
|
|
6
6
|
inquiry
|
|
7
7
|
.command('create')
|
|
8
8
|
.description('创建询价单')
|
|
9
|
-
.requiredOption('-p, --product <
|
|
10
|
-
.option('-o, --oe <number>', 'OE
|
|
11
|
-
.option('-
|
|
12
|
-
.option('-
|
|
9
|
+
.requiredOption('-p, --product <names...>', '配件名称,支持多个(如 -p 刹车片 机油 雨刷)')
|
|
10
|
+
.option('-o, --oe <number>', 'OE 编号(单配件时使用)')
|
|
11
|
+
.option('-b, --brand <code>', '品牌代码(如 VW、TOYOTA),不传则列出可选品牌')
|
|
12
|
+
.option('--brand-name <name>', '品牌名称(配合 --brand 使用)')
|
|
13
|
+
.option('--vin <vin>', 'VIN 码(自动解析品牌车型)')
|
|
14
|
+
.option('-m, --model <model>', '车型(如 朗逸)')
|
|
15
|
+
.option('-q, --quantity <n>', '数量(单配件时使用)', '1')
|
|
13
16
|
.option('-n, --note <text>', '备注')
|
|
14
17
|
.action(async (opts) => {
|
|
15
18
|
const adapter = getAdapter();
|
|
19
|
+
|
|
20
|
+
let carBrandId = opts.brand || '';
|
|
21
|
+
let carBrandName = opts.brandName || '';
|
|
22
|
+
let carModelName = opts.model || '';
|
|
23
|
+
|
|
24
|
+
// 有 VIN 时先尝试自动解析品牌车型
|
|
25
|
+
if (opts.vin && !carBrandId) {
|
|
26
|
+
process.stdout.write(`正在解析 VIN: ${opts.vin} ...`);
|
|
27
|
+
const model = await adapter.getCarModelByVin(opts.vin);
|
|
28
|
+
if (model) {
|
|
29
|
+
carBrandId = model.carBrandId || '';
|
|
30
|
+
carBrandName = model.carBrandName || '';
|
|
31
|
+
carModelName = model.carModelName || carModelName;
|
|
32
|
+
console.log(` ${carBrandName} ${carModelName}`);
|
|
33
|
+
} else {
|
|
34
|
+
console.log(' 无法识别,请手动选择品牌');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 没有品牌时列出可选品牌让用户选
|
|
39
|
+
if (!carBrandId) {
|
|
40
|
+
const brands = await adapter.listSupportBrands();
|
|
41
|
+
if (brands.length === 0) {
|
|
42
|
+
console.error('✗ 无法获取品牌列表,请使用 --brand 手动指定(如 --brand VW --brand-name 大众)');
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
console.log('\n支持的品牌:');
|
|
46
|
+
brands.forEach((b, i) => {
|
|
47
|
+
process.stdout.write(` ${String(i + 1).padStart(2)}. ${b.carBrandCode.padEnd(12)} ${b.carBrandName}\n`);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const { createInterface } = await import('readline');
|
|
51
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
52
|
+
const ask = (q) => new Promise((r) => rl.question(q, r));
|
|
53
|
+
const idx = parseInt(await ask(`选择品牌 (1-${brands.length}): `), 10) - 1;
|
|
54
|
+
rl.close();
|
|
55
|
+
|
|
56
|
+
const selected = brands[idx];
|
|
57
|
+
if (!selected) { console.error('✗ 无效选择'); process.exit(1); }
|
|
58
|
+
carBrandId = selected.carBrandCode;
|
|
59
|
+
carBrandName = selected.carBrandName;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 多配件:每个 product 对应一个 need
|
|
63
|
+
const products = Array.isArray(opts.product) ? opts.product : [opts.product];
|
|
64
|
+
|
|
16
65
|
const record = await adapter.createInquiry({
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
66
|
+
products, // 多配件数组
|
|
67
|
+
product: products[0], // 兼容单配件展示
|
|
68
|
+
oeNumber: products.length === 1 ? (opts.oe || '') : '',
|
|
69
|
+
vehicle: carModelName,
|
|
70
|
+
carBrandId,
|
|
71
|
+
carBrandName,
|
|
72
|
+
vin: opts.vin || '',
|
|
73
|
+
quantity: products.length === 1 ? opts.quantity : '1',
|
|
74
|
+
note: opts.note || '',
|
|
22
75
|
});
|
|
76
|
+
|
|
23
77
|
console.log(`✓ 询价单已创建: ${record.id}`);
|
|
24
|
-
console.log(
|
|
78
|
+
console.log(` 车型: ${record.vehicle || '-'} 配件: ${products.join('、')} 数量: ${products.length > 1 ? '各1' : record.quantity}`);
|
|
25
79
|
});
|
|
26
80
|
|
|
27
81
|
inquiry
|
|
@@ -64,6 +118,79 @@ export function registerInquiryCommands(program) {
|
|
|
64
118
|
}
|
|
65
119
|
});
|
|
66
120
|
|
|
121
|
+
inquiry
|
|
122
|
+
.command('watch')
|
|
123
|
+
.description('监听询价单报价状态(有新报价时提醒)')
|
|
124
|
+
.argument('<id>', '询价单 ID')
|
|
125
|
+
.option('-i, --interval <seconds>', '轮询间隔(秒)', '30')
|
|
126
|
+
.option('--timeout <seconds>', '最长等待时间(秒),超时后退出,0=不限制', '0')
|
|
127
|
+
.option('--exit-on-quotes <n>', '收到 n 条报价后自动退出', '0')
|
|
128
|
+
.option('--json', '以 JSON 格式输出结果(适合脚本/AI 调用)')
|
|
129
|
+
.action(async (id, opts) => {
|
|
130
|
+
const adapter = getAdapter();
|
|
131
|
+
const interval = Math.max(10, parseInt(opts.interval, 10)) * 1000;
|
|
132
|
+
const timeout = parseInt(opts.timeout, 10) * 1000;
|
|
133
|
+
const exitOnQuotes = parseInt(opts.exitOnQuotes, 10);
|
|
134
|
+
const startTime = Date.now();
|
|
135
|
+
|
|
136
|
+
if (!opts.json) {
|
|
137
|
+
console.log(`监听询价单 ${id},每 ${interval / 1000} 秒检查一次,Ctrl+C 退出\n`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let lastQuoteCount = 0;
|
|
141
|
+
let lastStatus = '';
|
|
142
|
+
|
|
143
|
+
const check = async () => {
|
|
144
|
+
try {
|
|
145
|
+
const record = await adapter.getInquiry(id);
|
|
146
|
+
const replies = await adapter.listReplies(id);
|
|
147
|
+
const count = replies.length;
|
|
148
|
+
const time = new Date().toLocaleTimeString();
|
|
149
|
+
|
|
150
|
+
// 超时退出
|
|
151
|
+
if (timeout > 0 && Date.now() - startTime >= timeout) {
|
|
152
|
+
if (opts.json) {
|
|
153
|
+
console.log(JSON.stringify({ inquiryId: id, status: record.status, quotes: replies, timedOut: true }));
|
|
154
|
+
} else {
|
|
155
|
+
console.log(`\n[${time}] 等待超时,共 ${count} 条报价`);
|
|
156
|
+
}
|
|
157
|
+
process.exit(0);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 达到目标报价数退出
|
|
161
|
+
if (exitOnQuotes > 0 && count >= exitOnQuotes) {
|
|
162
|
+
if (opts.json) {
|
|
163
|
+
console.log(JSON.stringify({ inquiryId: id, status: record.status, quotes: replies }));
|
|
164
|
+
} else {
|
|
165
|
+
console.log(`\n[${time}] 已收到 ${count} 条报价,退出监听`);
|
|
166
|
+
replies.forEach(r => console.log(` ${r.supplier} | ¥${r.price} | ${r.brand || '-'} | ${r.delivery ? r.delivery + '天' : '货期未知'}`));
|
|
167
|
+
}
|
|
168
|
+
process.exit(0);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (count > lastQuoteCount) {
|
|
172
|
+
if (!opts.json) {
|
|
173
|
+
console.log(`\n[${time}] 🔔 新报价!共 ${count} 条`);
|
|
174
|
+
replies.slice(lastQuoteCount).forEach(r => {
|
|
175
|
+
console.log(` ${r.supplier} | ¥${r.price} | ${r.brand || '-'} | ${r.delivery ? r.delivery + '天' : '货期未知'}`);
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
lastQuoteCount = count;
|
|
179
|
+
} else if (record.status !== lastStatus) {
|
|
180
|
+
if (!opts.json) console.log(`[${time}] 状态变更: ${lastStatus || '-'} → ${record.status} 报价: ${count} 条`);
|
|
181
|
+
lastStatus = record.status;
|
|
182
|
+
} else {
|
|
183
|
+
if (!opts.json) process.stdout.write(`\r[${time}] 等待报价... 当前 ${count} 条`);
|
|
184
|
+
}
|
|
185
|
+
} catch (e) {
|
|
186
|
+
if (!opts.json) console.error(`\n[错误] ${e.message}`);
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
await check();
|
|
191
|
+
setInterval(check, interval);
|
|
192
|
+
});
|
|
193
|
+
|
|
67
194
|
inquiry
|
|
68
195
|
.command('close')
|
|
69
196
|
.description('关闭询价单')
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 登录/登出命令
|
|
3
|
+
*/
|
|
4
|
+
import { loginWithPassword, loginWithCellphone, sendLoginCode, sendRegisterCode, registerUser, saveCompanyInfo, fetchAreas, checkAccountExists, isLoggedIn, logout } from '../auth.mjs';
|
|
5
|
+
import { Store } from '../store.mjs';
|
|
6
|
+
import { createInterface } from 'readline';
|
|
7
|
+
import { API_BASE_DEFAULT, TEST_PHONE } from '../constants.mjs';
|
|
8
|
+
|
|
9
|
+
export function registerLoginCommands(program) {
|
|
10
|
+
program
|
|
11
|
+
.command('login')
|
|
12
|
+
.description('登录平台(获取 API 访问令牌)')
|
|
13
|
+
.option('-u, --username <name>', '用户名/手机号')
|
|
14
|
+
.option('-p, --password <pwd>', '密码')
|
|
15
|
+
.option('--sms', '使用短信验证码登录')
|
|
16
|
+
.option('--test', `测试模式:使用测试账号 ${TEST_PHONE} 发送验证码(短信打到团队群)`)
|
|
17
|
+
.option('--api-base <url>', 'API 地址', API_BASE_DEFAULT)
|
|
18
|
+
.action(async (opts) => {
|
|
19
|
+
const store = new Store();
|
|
20
|
+
const config = store.getConfig();
|
|
21
|
+
|
|
22
|
+
const apiBase = opts.apiBase || config.apiBase || API_BASE_DEFAULT;
|
|
23
|
+
store.setConfig({ apiBase, mode: 'api' });
|
|
24
|
+
|
|
25
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
26
|
+
const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
let tokenData;
|
|
30
|
+
|
|
31
|
+
if (opts.test || opts.sms) {
|
|
32
|
+
const cellphone = opts.test ? TEST_PHONE : (opts.username || await ask('手机号: '));
|
|
33
|
+
|
|
34
|
+
if (opts.test) {
|
|
35
|
+
console.log(`[测试模式] 使用测试账号: ${TEST_PHONE}`);
|
|
36
|
+
console.log(`[测试模式] 验证码将发送到团队群,请在群里查收`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
process.stdout.write('正在检查账号...');
|
|
40
|
+
const exists = await checkAccountExists(apiBase, cellphone);
|
|
41
|
+
if (!exists) {
|
|
42
|
+
console.log('');
|
|
43
|
+
console.error(`✗ 该手机号尚未注册,请前往 casstime APP 完成注册后再使用 CLI`);
|
|
44
|
+
rl.close();
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
console.log(' 已确认');
|
|
48
|
+
|
|
49
|
+
process.stdout.write('正在发送验证码...');
|
|
50
|
+
await sendLoginCode(apiBase, cellphone);
|
|
51
|
+
console.log(' 已发送');
|
|
52
|
+
|
|
53
|
+
const verifyCode = await ask('验证码: ');
|
|
54
|
+
rl.close();
|
|
55
|
+
|
|
56
|
+
tokenData = await loginWithCellphone(apiBase, cellphone, verifyCode);
|
|
57
|
+
} else {
|
|
58
|
+
const username = opts.username || await ask('账号: ');
|
|
59
|
+
|
|
60
|
+
process.stdout.write('正在检查账号...');
|
|
61
|
+
const exists = await checkAccountExists(apiBase, username);
|
|
62
|
+
if (!exists) {
|
|
63
|
+
console.log('');
|
|
64
|
+
console.error(`✗ 该账号尚未注册,请前往 casstime APP 完成注册后再使用 CLI`);
|
|
65
|
+
rl.close();
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
console.log(' 已确认');
|
|
69
|
+
|
|
70
|
+
const password = opts.password || await ask('密码: ');
|
|
71
|
+
rl.close();
|
|
72
|
+
|
|
73
|
+
tokenData = await loginWithPassword(apiBase, username, password);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
console.log(`✓ 登录成功`);
|
|
77
|
+
console.log(` 用户: ${tokenData.userLoginId || opts.username}`);
|
|
78
|
+
console.log(` Token 有效期: ${Math.round((tokenData.expiresIn || 7200) / 60)} 分钟`);
|
|
79
|
+
console.log(` 模式已切换为: api`);
|
|
80
|
+
} catch (err) {
|
|
81
|
+
rl.close();
|
|
82
|
+
console.error(`✗ 登录失败: ${err.message}`);
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
program
|
|
88
|
+
.command('register')
|
|
89
|
+
.description('注册新账号')
|
|
90
|
+
.option('-u, --username <name>', '用户名')
|
|
91
|
+
.option('--phone <phone>', '手机号')
|
|
92
|
+
.option('-p, --password <pwd>', '密码')
|
|
93
|
+
.option('--code <code>', '验证码(非交互:先用 --send-code 发送)')
|
|
94
|
+
.option('--send-code', '仅发送注册验证码,不完成注册')
|
|
95
|
+
.option('--company <name>', '公司/门店名称')
|
|
96
|
+
.option('--province-id <id>', '省级 geoId(如 CN-11)')
|
|
97
|
+
.option('--province-name <name>', '省名称(如 北京市)')
|
|
98
|
+
.option('--city-id <id>', '市级 geoId(如 1351)')
|
|
99
|
+
.option('--city-name <name>', '市名称')
|
|
100
|
+
.option('--county-id <id>', '区县 geoId(如 9629)')
|
|
101
|
+
.option('--county-name <name>', '区县名称')
|
|
102
|
+
.option('--api-base <url>', 'API 地址', API_BASE_DEFAULT)
|
|
103
|
+
.action(async (opts) => {
|
|
104
|
+
const store = new Store();
|
|
105
|
+
const config = store.getConfig();
|
|
106
|
+
const apiBase = opts.apiBase || config.apiBase || API_BASE_DEFAULT;
|
|
107
|
+
|
|
108
|
+
// 仅发送验证码模式
|
|
109
|
+
if (opts.sendCode) {
|
|
110
|
+
if (!opts.phone) { console.error('✗ 请提供 --phone <手机号>'); process.exit(1); }
|
|
111
|
+
process.stdout.write('正在发送验证码...');
|
|
112
|
+
await sendRegisterCode(apiBase, opts.phone);
|
|
113
|
+
console.log(' 已发送,请查收短信后带 --code 完成注册');
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const isNonInteractive = opts.phone && opts.password && opts.username && opts.code;
|
|
118
|
+
|
|
119
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
120
|
+
const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
|
|
121
|
+
const askHidden = (q) => new Promise((resolve) => {
|
|
122
|
+
process.stdout.write(q);
|
|
123
|
+
process.stdin.setRawMode?.(true);
|
|
124
|
+
let input = '';
|
|
125
|
+
const onData = (ch) => {
|
|
126
|
+
ch = ch.toString();
|
|
127
|
+
if (ch === '\n' || ch === '\r') {
|
|
128
|
+
process.stdin.setRawMode?.(false);
|
|
129
|
+
process.stdin.removeListener('data', onData);
|
|
130
|
+
process.stdout.write('\n');
|
|
131
|
+
resolve(input);
|
|
132
|
+
} else if (ch === '') { process.exit(); }
|
|
133
|
+
else if (ch === '') { input = input.slice(0, -1); }
|
|
134
|
+
else { input += ch; process.stdout.write('*'); }
|
|
135
|
+
};
|
|
136
|
+
process.stdin.resume();
|
|
137
|
+
process.stdin.setEncoding('utf8');
|
|
138
|
+
process.stdin.on('data', onData);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const cellphone = opts.phone || await ask('手机号: ');
|
|
143
|
+
|
|
144
|
+
process.stdout.write('正在检查账号...');
|
|
145
|
+
const exists = await checkAccountExists(apiBase, cellphone);
|
|
146
|
+
console.log(exists ? ' 已注册' : ' 未注册');
|
|
147
|
+
if (exists) {
|
|
148
|
+
console.error('✗ 该手机号已注册,请直接登录: quote login');
|
|
149
|
+
rl.close(); process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (!isNonInteractive) {
|
|
153
|
+
process.stdout.write('正在发送验证码...');
|
|
154
|
+
await sendRegisterCode(apiBase, cellphone);
|
|
155
|
+
console.log(' 已发送');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const verifyCode = opts.code || await ask('验证码: ');
|
|
159
|
+
const username = opts.username || await ask('用户名: ');
|
|
160
|
+
const password = opts.password || await askHidden('密码: ');
|
|
161
|
+
|
|
162
|
+
if (!isNonInteractive) rl.close();
|
|
163
|
+
|
|
164
|
+
await registerUser(apiBase, { cellphone, password, username, verificationCode: verifyCode });
|
|
165
|
+
console.log('✓ 注册成功,正在自动登录...');
|
|
166
|
+
|
|
167
|
+
store.setConfig({ apiBase, mode: 'api' });
|
|
168
|
+
const tokenData = await loginWithPassword(apiBase, cellphone, password);
|
|
169
|
+
console.log(`✓ 登录成功 用户: ${tokenData.userLoginId || username}`);
|
|
170
|
+
|
|
171
|
+
// 公司信息 —— 有 flag 则非交互
|
|
172
|
+
if (opts.company && opts.provinceId && opts.cityId && opts.countyId) {
|
|
173
|
+
await saveCompanyInfo(apiBase, {
|
|
174
|
+
companyName: opts.company,
|
|
175
|
+
provinceId: opts.provinceId, provinceName: opts.provinceName || '',
|
|
176
|
+
cityId: opts.cityId, cityName: opts.cityName || '',
|
|
177
|
+
countyId: opts.countyId, countyName: opts.countyName || '',
|
|
178
|
+
});
|
|
179
|
+
console.log('✓ 公司信息已保存,使用 quote inquiry create 发布询价');
|
|
180
|
+
rl.close();
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
console.log('\n完善公司/门店信息(发布询价必须):');
|
|
185
|
+
const companyName = await ask('公司/门店名称: ');
|
|
186
|
+
|
|
187
|
+
const provinces = await fetchAreas(apiBase, 'CHN');
|
|
188
|
+
provinces.forEach((p, i) => process.stdout.write(` ${String(i + 1).padStart(2)}. ${p.geoName}\n`));
|
|
189
|
+
const province = provinces[parseInt(await ask(`选择省份 (1-${provinces.length}): `), 10) - 1];
|
|
190
|
+
|
|
191
|
+
const cities = await fetchAreas(apiBase, province.geoId);
|
|
192
|
+
cities.forEach((c, i) => process.stdout.write(` ${String(i + 1).padStart(2)}. ${c.geoName}\n`));
|
|
193
|
+
const city = cities[parseInt(await ask(`选择城市 (1-${cities.length}): `), 10) - 1];
|
|
194
|
+
|
|
195
|
+
const counties = await fetchAreas(apiBase, city.geoId);
|
|
196
|
+
counties.forEach((d, i) => process.stdout.write(` ${String(i + 1).padStart(2)}. ${d.geoName}\n`));
|
|
197
|
+
const county = counties[parseInt(await ask(`选择区县 (1-${counties.length}): `), 10) - 1];
|
|
198
|
+
|
|
199
|
+
rl.close();
|
|
200
|
+
await saveCompanyInfo(apiBase, {
|
|
201
|
+
companyName,
|
|
202
|
+
provinceId: province.geoId, provinceName: province.geoName,
|
|
203
|
+
cityId: city.geoId, cityName: city.geoName,
|
|
204
|
+
countyId: county.geoId, countyName: county.geoName,
|
|
205
|
+
});
|
|
206
|
+
console.log('✓ 公司信息已保存,使用 quote inquiry create 发布询价');
|
|
207
|
+
} catch (err) {
|
|
208
|
+
rl.close();
|
|
209
|
+
console.error(`✗ 注册失败: ${err.message}`);
|
|
210
|
+
process.exit(1);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
program
|
|
215
|
+
.command('logout')
|
|
216
|
+
.description('登出(清除本地令牌)')
|
|
217
|
+
.action(() => {
|
|
218
|
+
if (!isLoggedIn()) { console.log('当前未登录'); return; }
|
|
219
|
+
logout();
|
|
220
|
+
console.log('✓ 已登出,令牌已清除');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
program
|
|
224
|
+
.command('whoami')
|
|
225
|
+
.description('查看当前登录状态')
|
|
226
|
+
.action(() => {
|
|
227
|
+
const store = new Store();
|
|
228
|
+
const config = store.getConfig();
|
|
229
|
+
|
|
230
|
+
if (!isLoggedIn()) {
|
|
231
|
+
console.log('未登录。执行 quote login 登录平台。');
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const expiresAt = config.tokenExpiresAt;
|
|
236
|
+
const now = Date.now();
|
|
237
|
+
const remaining = expiresAt ? Math.max(0, Math.round((expiresAt - now) / 60_000)) : '?';
|
|
238
|
+
|
|
239
|
+
console.log(` 用户: ${config.userLoginId || '未知'}`);
|
|
240
|
+
console.log(` 模式: ${config.mode || 'local'}`);
|
|
241
|
+
console.log(` API: ${config.apiBase || '未配置'}`);
|
|
242
|
+
console.log(` Token 剩余: ${remaining} 分钟`);
|
|
243
|
+
});
|
|
244
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 全局常量
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const API_BASE_DEFAULT = 'https://ec-hwbeta.casstime.com/terminal-api-v2';
|
|
6
|
+
|
|
7
|
+
// 模拟移动端 UA,用于通过 API 网关版本校验
|
|
8
|
+
export const APP_USER_AGENT = 'cassapp/7.9.0.0 iOS/26.5 Apple/iPhone 13';
|
|
9
|
+
|
|
10
|
+
// 测试模式账号 — 短信发到团队群里(beta 环境已注册)
|
|
11
|
+
export const TEST_PHONE = '18162213812';
|
package/src/store.mjs
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* 本地 JSON 文件存储层
|
|
6
|
-
* 数据存放在
|
|
7
|
+
* 数据存放在 ~/.quote/ 下,全局唯一,不受 cwd 影响
|
|
7
8
|
*/
|
|
8
9
|
export class Store {
|
|
9
|
-
constructor(
|
|
10
|
-
this.root = join(
|
|
10
|
+
constructor() {
|
|
11
|
+
this.root = join(homedir(), '.quote');
|
|
11
12
|
this.dirs = {
|
|
12
13
|
inquiries: join(this.root, 'inquiries'),
|
|
13
|
-
replies:
|
|
14
|
-
orders:
|
|
14
|
+
replies: join(this.root, 'replies'),
|
|
15
|
+
orders: join(this.root, 'orders'),
|
|
15
16
|
};
|
|
16
17
|
this.configPath = join(this.root, 'config.json');
|
|
17
18
|
}
|