@xinhe-oa/cli 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +159 -2
- package/bin/xinhe-oa-cli.js +13 -0
- package/package.json +40 -4
- package/src/commands/application.js +244 -0
- package/src/commands/approval.js +275 -0
- package/src/commands/attachment.js +202 -0
- package/src/commands/bitable.js +3040 -0
- package/src/commands/config.js +186 -0
- package/src/commands/dashboard.js +132 -0
- package/src/commands/doctor.js +975 -0
- package/src/commands/event.js +151 -0
- package/src/commands/file.js +1474 -0
- package/src/commands/index.js +78 -0
- package/src/commands/investor.js +179 -0
- package/src/commands/member.js +1341 -0
- package/src/commands/org.js +399 -0
- package/src/commands/report.js +908 -0
- package/src/commands/schema.js +75 -0
- package/src/commands/session.js +516 -0
- package/src/commands/skills.js +1117 -0
- package/src/commands/tenant.js +202 -0
- package/src/commands/workflow.js +176 -0
- package/src/core/args.js +901 -0
- package/src/core/auth.js +349 -0
- package/src/core/checkpoint.js +226 -0
- package/src/core/config.js +407 -0
- package/src/core/error.js +217 -0
- package/src/core/http.js +903 -0
- package/src/core/import.js +753 -0
- package/src/core/output.js +300 -0
- package/src/core/runtime.js +302 -0
- package/src/core/util.js +195 -0
- package/src/core/values.js +278 -0
- package/src/core/xlsx.js +321 -0
- package/src/main.js +205 -0
- package/src/spec/agent_guide.md +55 -0
- package/src/spec/command_surface.json +27073 -0
- package/src/spec/embedded_skills.json +19 -0
- package/src/version.js +16 -0
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
// `bitable table --import` 的客户端解析:CSV/XLSX → 类型推断 → 分批 → 取值转换。
|
|
2
|
+
//
|
|
3
|
+
// 这是"业务计算在客户端"的第一个落点(契约 §6.2):平台只存结果,
|
|
4
|
+
// 解析、推断、分批、幂等键全在本地做完。
|
|
5
|
+
//
|
|
6
|
+
// 推断规则(**可解释、可覆盖**):
|
|
7
|
+
//
|
|
8
|
+
// | 判定 | 依据 | 落到哪个字段类型 |
|
|
9
|
+
// |---|---|---|
|
|
10
|
+
// | 布尔 | 全部取值 ∈ {true,false,yes,no,是,否,...} | `boolean` |
|
|
11
|
+
// | 日期 | 全部匹配 `YYYY-MM-DD` / `YYYY/M/D` / `YYYY.M.D` / `YYYYMMDD`(且是真实日历日) | `date` |
|
|
12
|
+
// | 金额 | 出现 `¥$¥€`、千分位(`1,234.00`)、会计负数(`(123.45)`) | `currency` |
|
|
13
|
+
// | 数值 | 全部是整数/小数(可带 `%` 或正负号) | `number` |
|
|
14
|
+
// | 文本 | 其余 | `text` |
|
|
15
|
+
//
|
|
16
|
+
// 判定顺序是刻意的:**日期先于数值**(`20260301` 既是数字也是日期,
|
|
17
|
+
// 但在单据里它是日期),**金额先于数值**(千分位小数是金额的语言)。
|
|
18
|
+
// 想推翻推断就 `--type 列名=类型`,它以最高优先级生效。
|
|
19
|
+
//
|
|
20
|
+
// 一条硬约束:列的 `semantic_key` 必须匹配服务端的 `^[a-z][a-z0-9_]{0,63}$`。
|
|
21
|
+
// 中文列名做不了机器名,所以中文列自动得到 `col_3` 这样的键,
|
|
22
|
+
// **显示名仍是原列名** —— `values` 两种写法都接受(semantic_key 或 field_id),
|
|
23
|
+
// 导入结果里会把对应关系逐列返回,后续读写照着用即可。
|
|
24
|
+
//
|
|
25
|
+
// 本模块是 `cli/crates/xinhe-oa-cli/src/import.rs` 的逐函数移植(含全部单测口径)。
|
|
26
|
+
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import { createHash } from 'node:crypto';
|
|
30
|
+
|
|
31
|
+
import { CliError } from './error.js';
|
|
32
|
+
import { rustIoErrorText } from './util.js';
|
|
33
|
+
import { readXlsx } from './xlsx.js';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 单批记录数上限,**与服务端 `/me` 公布的 `rate_limits.row_ops_batch_max` 同值**。
|
|
37
|
+
*
|
|
38
|
+
* 2026-09-29 之前这里是 200,而服务端一直是 500 —— 客户端自己把批切得更小,
|
|
39
|
+
* 每批都要付一次固定开销(认证、幂等查重、事务提交)。2 万行按 200 切是 106 批,
|
|
40
|
+
* 按 500 切是 43 批:省下的正是这 63 次的固定开销。
|
|
41
|
+
*
|
|
42
|
+
* 服务端会**真的**按这个数收口(超了报 `request.invalid`),所以它是契约而不是建议。
|
|
43
|
+
*/
|
|
44
|
+
export const MAX_BATCH = 500;
|
|
45
|
+
|
|
46
|
+
/** 客户端支持推断出的字段类型(与服务端 `FIELD_TYPES` 同一份名单)。 */
|
|
47
|
+
export const FIELD_TYPES = [
|
|
48
|
+
'text',
|
|
49
|
+
'number',
|
|
50
|
+
'currency',
|
|
51
|
+
'date',
|
|
52
|
+
'single_select',
|
|
53
|
+
'multi_select',
|
|
54
|
+
'boolean',
|
|
55
|
+
'person',
|
|
56
|
+
'attachment_ref',
|
|
57
|
+
'relation',
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
/** 列的类型。 */
|
|
61
|
+
export const ColumnType = Object.freeze({
|
|
62
|
+
Text: 'text',
|
|
63
|
+
Number: 'number',
|
|
64
|
+
Currency: 'currency',
|
|
65
|
+
Date: 'date',
|
|
66
|
+
Boolean: 'boolean',
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/** 为什么这么推断(写进结果,让人能复核,而不是"机器说它是金额")。 */
|
|
70
|
+
export function typeReason(type) {
|
|
71
|
+
switch (type) {
|
|
72
|
+
case ColumnType.Text:
|
|
73
|
+
return '有非数值/非日期的取值';
|
|
74
|
+
case ColumnType.Number:
|
|
75
|
+
return '全部取值都是数值(含百分比)';
|
|
76
|
+
case ColumnType.Currency:
|
|
77
|
+
return '出现币种符号/千分位/会计负数';
|
|
78
|
+
case ColumnType.Date:
|
|
79
|
+
return '全部取值都是日期';
|
|
80
|
+
default:
|
|
81
|
+
return '全部取值都是布尔字面量';
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 服务端字段类型 → 列类型。
|
|
87
|
+
*
|
|
88
|
+
* `--append` 追加到已有表时用:写记录要按**表里那个字段的类型**来转值,
|
|
89
|
+
* 而不是按这次文件推断出来的类型(推断只是给新建字段用的建议)。
|
|
90
|
+
* `single_select` / `multi_select` / `person` / `attachment_ref` / `relation`
|
|
91
|
+
* 没有值级推断,返回 `null` —— 调用方按文本原样写,让服务端校验。
|
|
92
|
+
*/
|
|
93
|
+
export function columnTypeFromServer(raw) {
|
|
94
|
+
switch (String(raw ?? '').trim().toLowerCase()) {
|
|
95
|
+
case 'text':
|
|
96
|
+
return ColumnType.Text;
|
|
97
|
+
case 'number':
|
|
98
|
+
return ColumnType.Number;
|
|
99
|
+
case 'currency':
|
|
100
|
+
return ColumnType.Currency;
|
|
101
|
+
case 'date':
|
|
102
|
+
return ColumnType.Date;
|
|
103
|
+
case 'boolean':
|
|
104
|
+
return ColumnType.Boolean;
|
|
105
|
+
default:
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** `--type 列名=类型` 的右侧:只认能推断出来的那几种(`bool` 是别名)。 */
|
|
111
|
+
export function parseColumnType(raw) {
|
|
112
|
+
switch (String(raw ?? '').trim().toLowerCase()) {
|
|
113
|
+
case 'text':
|
|
114
|
+
return ColumnType.Text;
|
|
115
|
+
case 'number':
|
|
116
|
+
return ColumnType.Number;
|
|
117
|
+
case 'currency':
|
|
118
|
+
return ColumnType.Currency;
|
|
119
|
+
case 'date':
|
|
120
|
+
return ColumnType.Date;
|
|
121
|
+
case 'boolean':
|
|
122
|
+
case 'bool':
|
|
123
|
+
return ColumnType.Boolean;
|
|
124
|
+
default:
|
|
125
|
+
throw CliError.usage(
|
|
126
|
+
`列类型只能是 ${FIELD_TYPES.join(' / ')}(收到 ${JSON.stringify(
|
|
127
|
+
String(raw ?? '').trim().toLowerCase(),
|
|
128
|
+
)})`,
|
|
129
|
+
).withHint('`--type` 只覆盖类型;person / relation / 选项类字段请建表后用 bitable field --create');
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// 值级推断
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
export function isBlank(raw) {
|
|
137
|
+
const trimmed = String(raw ?? '').trim();
|
|
138
|
+
return (
|
|
139
|
+
trimmed.length === 0 ||
|
|
140
|
+
trimmed === '-' ||
|
|
141
|
+
trimmed === '—' ||
|
|
142
|
+
trimmed.toLowerCase() === 'null'
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 布尔字面量(与服务端 `_coerce_boolean` 接受的一致,再加中文)。 */
|
|
147
|
+
export function asBool(raw) {
|
|
148
|
+
switch (String(raw ?? '').trim().toLowerCase()) {
|
|
149
|
+
case 'true':
|
|
150
|
+
case '1':
|
|
151
|
+
case 't':
|
|
152
|
+
case 'yes':
|
|
153
|
+
case 'y':
|
|
154
|
+
case '是':
|
|
155
|
+
case '有':
|
|
156
|
+
return true;
|
|
157
|
+
case 'false':
|
|
158
|
+
case '0':
|
|
159
|
+
case 'f':
|
|
160
|
+
case 'no':
|
|
161
|
+
case 'n':
|
|
162
|
+
case '否':
|
|
163
|
+
case '无':
|
|
164
|
+
return false;
|
|
165
|
+
default:
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const CURRENCY_SYMBOLS = ['¥', '¥', '$', '€', '£', '元', 'RMB', 'rmb', 'CNY', 'cny'];
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* 去掉币种符号与千分位;会计负数 `(123.45)` → `-123.45`。
|
|
174
|
+
*
|
|
175
|
+
* @returns {{value: string, money: boolean, scale: number}|null}
|
|
176
|
+
* `money` = "这看起来像金额":千分位、币种符号、会计负数都是金额的语言;
|
|
177
|
+
* **百分比不是**(`12%` 是比率,不是钱)。
|
|
178
|
+
*/
|
|
179
|
+
export function normalizeNumber(raw) {
|
|
180
|
+
let text = String(raw ?? '').trim();
|
|
181
|
+
if (text.length === 0) return null;
|
|
182
|
+
// 会计负数:`(123.45)` 是财务报表里"负数"的写法,也是**金额**的信号。
|
|
183
|
+
let negative = false;
|
|
184
|
+
let parenNegative = false;
|
|
185
|
+
if (text.startsWith('(') && text.endsWith(')')) {
|
|
186
|
+
negative = true;
|
|
187
|
+
parenNegative = true;
|
|
188
|
+
text = text.slice(1, -1).trim();
|
|
189
|
+
}
|
|
190
|
+
let hasSymbol = false;
|
|
191
|
+
for (const symbol of CURRENCY_SYMBOLS) {
|
|
192
|
+
if (text.includes(symbol)) {
|
|
193
|
+
hasSymbol = true;
|
|
194
|
+
text = text.split(symbol).join('');
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (text.startsWith('-')) {
|
|
198
|
+
negative = !negative;
|
|
199
|
+
text = text.slice(1).trim();
|
|
200
|
+
} else if (text.startsWith('+')) {
|
|
201
|
+
text = text.slice(1).trim();
|
|
202
|
+
}
|
|
203
|
+
// 百分比:识别并**剥掉**,值仍是数值(`12%` → 12)
|
|
204
|
+
if (text.endsWith('%')) text = text.slice(0, -1).trim();
|
|
205
|
+
const trimmed = text.trim();
|
|
206
|
+
if (trimmed.length === 0) return null;
|
|
207
|
+
const hasThousands = trimmed.includes(',');
|
|
208
|
+
const cleaned = trimmed.split(',').join('');
|
|
209
|
+
const dot = cleaned.indexOf('.');
|
|
210
|
+
const integer = dot >= 0 ? cleaned.slice(0, dot) : cleaned;
|
|
211
|
+
const fraction = dot >= 0 ? cleaned.slice(dot + 1) : '';
|
|
212
|
+
if (integer.length === 0 && fraction.length === 0) return null;
|
|
213
|
+
if (!/^[0-9]*$/.test(integer) || !/^[0-9]*$/.test(fraction)) return null;
|
|
214
|
+
const scale = fraction.length;
|
|
215
|
+
return {
|
|
216
|
+
value: negative ? `-${cleaned}` : cleaned,
|
|
217
|
+
money: hasThousands || hasSymbol || parenNegative,
|
|
218
|
+
scale,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** 日期:只接受无歧义的写法(年月日的顺序在中文单据里几乎恒为 Y-M-D)。 */
|
|
223
|
+
export function asDate(raw) {
|
|
224
|
+
const text = String(raw ?? '').trim();
|
|
225
|
+
if (text.length === 0) return null;
|
|
226
|
+
const normalized = text
|
|
227
|
+
.split('年')
|
|
228
|
+
.join('-')
|
|
229
|
+
.split('月')
|
|
230
|
+
.join('-')
|
|
231
|
+
.split('日')
|
|
232
|
+
.join('')
|
|
233
|
+
.split('/')
|
|
234
|
+
.join('-')
|
|
235
|
+
.split('.')
|
|
236
|
+
.join('-')
|
|
237
|
+
.split('.')
|
|
238
|
+
.join('-')
|
|
239
|
+
.trim();
|
|
240
|
+
const parts = normalized.split('-').filter((part) => part.length > 0);
|
|
241
|
+
let year;
|
|
242
|
+
let month;
|
|
243
|
+
let day;
|
|
244
|
+
if (parts.length === 3) {
|
|
245
|
+
if (!/^[+-]?[0-9]+$/.test(parts[0]) || !/^[+-]?[0-9]+$/.test(parts[1]) || !/^[+-]?[0-9]+$/.test(parts[2])) {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
year = Number.parseInt(parts[0], 10);
|
|
249
|
+
month = Number.parseInt(parts[1], 10);
|
|
250
|
+
day = Number.parseInt(parts[2], 10);
|
|
251
|
+
} else if (parts.length === 1 && /^[0-9]{8}$/.test(parts[0])) {
|
|
252
|
+
// `YYYYMMDD` 的紧凑写法(纯 8 位数字)。
|
|
253
|
+
//
|
|
254
|
+
// **必须先确认它真的是 8 个 ASCII 数字,再切** —— Rust 那边踩过一个 panic:
|
|
255
|
+
// 只判字符串**字节**长度时,`81滞留`(2 + 3 + 3 = 8 字节、3 个字符)也进得来,
|
|
256
|
+
// 随后按字节切直接崩,整张表一条都导不进去。这类值在收费明细里真实存在
|
|
257
|
+
// (诊断编码 `84中龋`、渠道名 `美团3M`、姓名 `崔允xi`),不是理论边界。
|
|
258
|
+
// JS 的 `length` 是字符数、正则又钉死 ASCII 数字,天然不会犯这个错。
|
|
259
|
+
const digits = parts[0];
|
|
260
|
+
year = Number.parseInt(digits.slice(0, 4), 10);
|
|
261
|
+
month = Number.parseInt(digits.slice(4, 6), 10);
|
|
262
|
+
day = Number.parseInt(digits.slice(6, 8), 10);
|
|
263
|
+
} else {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
if (year < 1900 || year > 2999 || month < 1 || month > 12 || day < 1 || day > 31) return null;
|
|
267
|
+
const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
|
268
|
+
const maxDay =
|
|
269
|
+
month === 2 ? (leap ? 29 : 28) : [4, 6, 9, 11].includes(month) ? 30 : 31;
|
|
270
|
+
if (day > maxDay) return null;
|
|
271
|
+
return `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** 采样推断一列的类型(`samples` 里空白值不参与)。 */
|
|
275
|
+
export function inferColumn(samples) {
|
|
276
|
+
const values = samples.filter((value) => !isBlank(value));
|
|
277
|
+
if (values.length === 0) return { type: ColumnType.Text, scale: 0 };
|
|
278
|
+
const total = values.length;
|
|
279
|
+
let bools = 0;
|
|
280
|
+
let dates = 0;
|
|
281
|
+
let numbers = 0;
|
|
282
|
+
let money = 0;
|
|
283
|
+
let maxScale = 0;
|
|
284
|
+
for (const value of values) {
|
|
285
|
+
if (asBool(value) !== null) bools += 1;
|
|
286
|
+
if (asDate(value) !== null) dates += 1;
|
|
287
|
+
const number = normalizeNumber(value);
|
|
288
|
+
if (number !== null) {
|
|
289
|
+
numbers += 1;
|
|
290
|
+
maxScale = Math.max(maxScale, number.scale);
|
|
291
|
+
if (number.money) money += 1;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (bools === total) return { type: ColumnType.Boolean, scale: 0 };
|
|
295
|
+
if (dates === total) return { type: ColumnType.Date, scale: 0 };
|
|
296
|
+
if (numbers === total) {
|
|
297
|
+
return money > 0
|
|
298
|
+
? { type: ColumnType.Currency, scale: maxScale }
|
|
299
|
+
: { type: ColumnType.Number, scale: maxScale };
|
|
300
|
+
}
|
|
301
|
+
return { type: ColumnType.Text, scale: maxScale };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* 列名看起来是不是**标识符**(流水号 / 手机号 / 门诊号 / ID …)。
|
|
306
|
+
*
|
|
307
|
+
* 为什么需要它(真实现场问题):结算流水号、手机号、门诊号、票据号**全是纯数字**,
|
|
308
|
+
* 采样推断必然把它们判成 `number`;而数字落库会掉前导零、丢精度,也没法当字符串比较。
|
|
309
|
+
* 现场的代价是"每一列都得手写 `--type 结算流水号=text`,漏一列就建错类型"——
|
|
310
|
+
* 而**类型建后不可改**(服务端语义:改类型 = 新建字段 + 回填),只能归档重导。
|
|
311
|
+
*
|
|
312
|
+
* 判据刻意保守:**宁可把数字判成文本,也不要把标识符判成数字**。
|
|
313
|
+
* 数字存成文本仍然可读、可排序、可导出;而标识符存成数字会**静默损坏**
|
|
314
|
+
* (`007` → `7`),且不可逆。
|
|
315
|
+
*/
|
|
316
|
+
export function looksLikeIdentifier(name) {
|
|
317
|
+
const markers = ['号', '编号', '流水', 'ID', 'Id', 'id', '证件', '手机', '电话', '邮编'];
|
|
318
|
+
return markers.some((marker) => String(name ?? '').includes(marker));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* 带列名的推断:先按取值推断,再把"像标识符的纯数字列"降级成文本。
|
|
323
|
+
*
|
|
324
|
+
* `noInfer = true`(`--no-infer`)时**一律文本**,只有 `--type` 能改写。
|
|
325
|
+
* 只降级 `Number`:`Currency` 有自己的金额标记(千分位/¥/会计负数),
|
|
326
|
+
* 那些标记本身就说明"这是钱",不该被列名里的一个"号"字推翻。
|
|
327
|
+
*/
|
|
328
|
+
export function inferColumnNamed(name, samples, noInfer) {
|
|
329
|
+
const inferred = inferColumn(samples);
|
|
330
|
+
if (noInfer) return { type: ColumnType.Text, scale: inferred.scale, downgraded: false };
|
|
331
|
+
if (inferred.type === ColumnType.Number && looksLikeIdentifier(name)) {
|
|
332
|
+
return { type: ColumnType.Text, scale: inferred.scale, downgraded: true };
|
|
333
|
+
}
|
|
334
|
+
return { ...inferred, downgraded: false };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** 中文列名做不了机器名 → 给一个稳定的 ASCII 键(`^[a-z][a-z0-9_]{0,63}$`)。 */
|
|
338
|
+
export function semanticKeyOf(name, index, taken) {
|
|
339
|
+
let ascii = '';
|
|
340
|
+
for (const ch of String(name ?? '')) {
|
|
341
|
+
if (/[A-Za-z0-9]/.test(ch)) ascii += ch.toLowerCase();
|
|
342
|
+
else if (ch.charCodeAt(0) < 128) ascii += '_';
|
|
343
|
+
// 非 ASCII(中文等)整段丢掉:它在机器名里没有合法的表示
|
|
344
|
+
}
|
|
345
|
+
while (ascii.includes('__')) ascii = ascii.split('__').join('_');
|
|
346
|
+
ascii = ascii.replace(/^_+/, '').replace(/_+$/, '');
|
|
347
|
+
if (ascii.length > 48) ascii = ascii.slice(0, 48);
|
|
348
|
+
let candidate =
|
|
349
|
+
ascii.length === 0 || !/^[a-z]/.test(ascii) ? `col_${index + 1}` : ascii;
|
|
350
|
+
// 重名去冲突:**逐字照抄 Rust 的那两行**(先按"去掉结尾数字"的基名加后缀,
|
|
351
|
+
// 再把这个后缀拼一次)—— 结果形如 `amount_2_2`。看着别扭,但机器名是
|
|
352
|
+
// 跨实现可比的契约:两边生成不同的键,同一份文件在两种实现下会建出不同字段。
|
|
353
|
+
let suffix = 2;
|
|
354
|
+
while (taken.includes(candidate)) {
|
|
355
|
+
const base = candidate.replace(/[0-9]+$/, '');
|
|
356
|
+
candidate = `${base}_${suffix}`;
|
|
357
|
+
candidate = `${candidate}_${suffix}`;
|
|
358
|
+
suffix += 1;
|
|
359
|
+
}
|
|
360
|
+
taken.push(candidate);
|
|
361
|
+
return candidate;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ---------------------------------------------------------------------------
|
|
365
|
+
// 解析:CSV / XLSX
|
|
366
|
+
// ---------------------------------------------------------------------------
|
|
367
|
+
/**
|
|
368
|
+
* 解析输入文件(按扩展名分派)。返回 `ParsedTable`:
|
|
369
|
+
* `{sourceFormat, sheetName, columns, rows, skippedEmptyRows}`。
|
|
370
|
+
*/
|
|
371
|
+
export /** OLE 复合文档(老式 `.xls`)的 8 字节签名。 */
|
|
372
|
+
const OLE_MAGIC = Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]);
|
|
373
|
+
|
|
374
|
+
/** 读文件头的 8 个字节;读不了就返回 null(后面交给正常的解析报错)。 */
|
|
375
|
+
function readMagicRaw(filePath) {
|
|
376
|
+
try {
|
|
377
|
+
const fd = fs.openSync(filePath, 'r');
|
|
378
|
+
try {
|
|
379
|
+
const head = Buffer.alloc(8);
|
|
380
|
+
const read = fs.readSync(fd, head, 0, 8, 0);
|
|
381
|
+
return read === 8 ? head : null;
|
|
382
|
+
} finally {
|
|
383
|
+
fs.closeSync(fd);
|
|
384
|
+
}
|
|
385
|
+
} catch {
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export function parseFile(file, sheet, overrides, noInfer) {
|
|
391
|
+
const extension = path.extname(String(file ?? '')).replace(/^\./, '').toLowerCase();
|
|
392
|
+
let grid;
|
|
393
|
+
let sourceFormat;
|
|
394
|
+
let sheetName = null;
|
|
395
|
+
if (extension === 'csv' || extension === 'txt') {
|
|
396
|
+
grid = parseCsv(file);
|
|
397
|
+
sourceFormat = 'csv';
|
|
398
|
+
} else if (extension === 'xlsx' || extension === 'xlsm' || extension === 'xls') {
|
|
399
|
+
// **老式 `.xls`(BIFF,OLE 复合文档)读不了**:Rust 版靠 calamine 支持它,
|
|
400
|
+
// Node 版只实现了 OOXML(.xlsx 就是一个 zip)。这里**先看魔术字节**再决定报什么错 ——
|
|
401
|
+
// 直接交给 zip 解析器会得到"这不是一个 zip:找不到中央目录",用户完全不知道
|
|
402
|
+
// 该怎么办。`.xls` 的签名是 `D0 CF 11 E0 A1 B1 1A E1`。
|
|
403
|
+
if (readMagicRaw(file) !== null && readMagicRaw(file).equals(OLE_MAGIC)) {
|
|
404
|
+
throw CliError.usage(
|
|
405
|
+
'这是老式 .xls(Excel 97-2003 二进制格式),当前客户端读不了',
|
|
406
|
+
).withHint('在 Excel / WPS 里「另存为 → .xlsx」或「另存为 → CSV UTF-8(逗号分隔)」再导入');
|
|
407
|
+
}
|
|
408
|
+
const read = readXlsx(file, sheet ?? null, { formatFloat, serialToDate: excelSerialToDate });
|
|
409
|
+
grid = read.grid;
|
|
410
|
+
sourceFormat = 'xlsx';
|
|
411
|
+
sheetName = read.sheetName;
|
|
412
|
+
} else {
|
|
413
|
+
throw CliError.usage(`不认识的扩展名 ${JSON.stringify(extension)}(支持 .csv / .xlsx)`).withHint(
|
|
414
|
+
'Excel 另存为 CSV,或直接用 .xlsx',
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
if (grid.length === 0) {
|
|
418
|
+
throw CliError.usage(`${file} 里没有数据`).withHint('确认文件不是空的、sheet 选对了');
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const header = grid.shift();
|
|
422
|
+
const columns = [];
|
|
423
|
+
const taken = [];
|
|
424
|
+
const headerSeen = [];
|
|
425
|
+
header.forEach((rawName, index) => {
|
|
426
|
+
let name = String(rawName ?? '').trim();
|
|
427
|
+
if (name.length === 0) name = `列${index + 1}`;
|
|
428
|
+
// 重名列:加序号(服务端的字段显示名不要求唯一,但列名是我们识别"同一列"的键)
|
|
429
|
+
if (headerSeen.includes(name)) {
|
|
430
|
+
let suffix = 2;
|
|
431
|
+
let candidate = `${name}_${suffix}`;
|
|
432
|
+
while (headerSeen.includes(candidate)) {
|
|
433
|
+
suffix += 1;
|
|
434
|
+
candidate = `${name}_${suffix}`;
|
|
435
|
+
}
|
|
436
|
+
name = candidate;
|
|
437
|
+
}
|
|
438
|
+
headerSeen.push(name);
|
|
439
|
+
columns.push({
|
|
440
|
+
name,
|
|
441
|
+
semanticKey: semanticKeyOf(name, index, taken),
|
|
442
|
+
columnType: ColumnType.Text,
|
|
443
|
+
reason: '',
|
|
444
|
+
maxScale: 0,
|
|
445
|
+
overridden: false,
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
// 行长不足的行补空(Excel 里尾列全空是常事),超长的截断并记数。
|
|
450
|
+
const rows = [];
|
|
451
|
+
let skippedEmptyRows = 0;
|
|
452
|
+
for (const row of grid) {
|
|
453
|
+
const padded = row.slice(0, columns.length);
|
|
454
|
+
while (padded.length < columns.length) padded.push('');
|
|
455
|
+
if (padded.every((cell) => isBlank(cell))) {
|
|
456
|
+
skippedEmptyRows += 1;
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
rows.push(padded);
|
|
460
|
+
}
|
|
461
|
+
if (rows.length === 0) throw CliError.usage(`${file} 里只有表头、没有数据行`);
|
|
462
|
+
|
|
463
|
+
// 逐列采样推断(最多看 500 行,够稳且不必为 10 万行文件重复解析)。
|
|
464
|
+
const sampleSize = Math.min(rows.length, 500);
|
|
465
|
+
columns.forEach((column, index) => {
|
|
466
|
+
const samples = rows.slice(0, sampleSize).map((row) => row[index]);
|
|
467
|
+
const inferred = inferColumnNamed(column.name, samples, noInfer);
|
|
468
|
+
const plain = inferColumn(samples);
|
|
469
|
+
let finalType;
|
|
470
|
+
let reason;
|
|
471
|
+
if (Object.prototype.hasOwnProperty.call(overrides, column.name)) {
|
|
472
|
+
finalType = overrides[column.name];
|
|
473
|
+
reason = '由 --type 指定';
|
|
474
|
+
} else if (noInfer) {
|
|
475
|
+
finalType = inferred.type;
|
|
476
|
+
reason = '由 --no-infer 指定(全部按文本导入)';
|
|
477
|
+
} else if (inferred.type !== plain.type) {
|
|
478
|
+
finalType = inferred.type;
|
|
479
|
+
reason = '列名像标识符(号/编号/流水/ID…)且取值全是数字 ⇒ 按文本,避免掉前导零';
|
|
480
|
+
} else {
|
|
481
|
+
finalType = inferred.type;
|
|
482
|
+
reason = typeReason(inferred.type);
|
|
483
|
+
}
|
|
484
|
+
column.overridden = Object.prototype.hasOwnProperty.call(overrides, column.name);
|
|
485
|
+
column.columnType = finalType;
|
|
486
|
+
column.reason = reason;
|
|
487
|
+
column.maxScale =
|
|
488
|
+
finalType === ColumnType.Currency
|
|
489
|
+
? // 金额超过两位小数:默认 precision=18/scale=2 会拒收 → 抬 scale(最多 6)。
|
|
490
|
+
Math.min(Math.max(inferred.scale, 2), 6)
|
|
491
|
+
: Math.min(inferred.scale, 10);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
// `--type` 里点名但文件里没有的列:当场报错,别让人以为生效了。
|
|
495
|
+
for (const name of Object.keys(overrides)) {
|
|
496
|
+
if (!columns.some((column) => column.name === name)) {
|
|
497
|
+
throw CliError.usage(`--type 里的列 ${JSON.stringify(name)} 不在文件的表头里`).withDetails({
|
|
498
|
+
available_columns: columns.map((column) => column.name),
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return { sourceFormat, sheetName, columns, rows, skippedEmptyRows, file };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** 解析 `--type 列名=类型`(键是显示名,值是列类型)。 */
|
|
507
|
+
export function parseTypeOverrides(items) {
|
|
508
|
+
const out = {};
|
|
509
|
+
for (const item of items) {
|
|
510
|
+
const index = String(item).indexOf('=');
|
|
511
|
+
if (index < 0) {
|
|
512
|
+
throw CliError.usage(`--type 的写法是 \`列名=类型\`,收到 ${JSON.stringify(item)}`).withHint(
|
|
513
|
+
'例:--type 金额=currency',
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
const name = String(item).slice(0, index).trim();
|
|
517
|
+
if (name.length === 0) {
|
|
518
|
+
throw CliError.usage(`--type 里的列名不能为空(${JSON.stringify(item)})`);
|
|
519
|
+
}
|
|
520
|
+
out[name] = parseColumnType(String(item).slice(index + 1));
|
|
521
|
+
}
|
|
522
|
+
return out;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** 猜测分隔符(企业里导出的 CSV 有逗号、分号、制表符、竖线四种)。 */
|
|
526
|
+
export function sniffDelimiter(firstLine) {
|
|
527
|
+
const candidates = [',', ';', '\t', '|'];
|
|
528
|
+
let best = ',';
|
|
529
|
+
let bestCount = 0;
|
|
530
|
+
for (const candidate of candidates) {
|
|
531
|
+
const count = String(firstLine ?? '').split(candidate).length - 1;
|
|
532
|
+
if (count > bestCount) {
|
|
533
|
+
best = candidate;
|
|
534
|
+
bestCount = count;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
return best;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** RFC4180 风格的 CSV 解析(引号内的分隔符/换行不算分隔)。 */
|
|
541
|
+
export function parseCsvText(text, delimiter) {
|
|
542
|
+
const rows = [];
|
|
543
|
+
let row = [];
|
|
544
|
+
let field = '';
|
|
545
|
+
let inQuotes = false;
|
|
546
|
+
let index = 0;
|
|
547
|
+
while (index < text.length) {
|
|
548
|
+
const ch = text[index];
|
|
549
|
+
if (inQuotes) {
|
|
550
|
+
if (ch === '"') {
|
|
551
|
+
if (text[index + 1] === '"') {
|
|
552
|
+
field += '"';
|
|
553
|
+
index += 2;
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
inQuotes = false;
|
|
557
|
+
index += 1;
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
field += ch;
|
|
561
|
+
index += 1;
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
if (ch === '"' && field.length === 0) {
|
|
565
|
+
inQuotes = true;
|
|
566
|
+
index += 1;
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
if (ch === delimiter) {
|
|
570
|
+
row.push(field);
|
|
571
|
+
field = '';
|
|
572
|
+
index += 1;
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
if (ch === '\r' || ch === '\n') {
|
|
576
|
+
if (ch === '\r' && text[index + 1] === '\n') index += 1;
|
|
577
|
+
row.push(field);
|
|
578
|
+
rows.push(row);
|
|
579
|
+
row = [];
|
|
580
|
+
field = '';
|
|
581
|
+
index += 1;
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
field += ch;
|
|
585
|
+
index += 1;
|
|
586
|
+
}
|
|
587
|
+
if (field.length > 0 || row.length > 0) {
|
|
588
|
+
row.push(field);
|
|
589
|
+
rows.push(row);
|
|
590
|
+
}
|
|
591
|
+
return rows;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** 读 CSV:**非 UTF-8 当场拒**(Excel 默认导出的 GBK 不许静默按 UTF-8 解)。 */
|
|
595
|
+
export function parseCsv(file) {
|
|
596
|
+
let bytes;
|
|
597
|
+
try {
|
|
598
|
+
bytes = fs.readFileSync(file);
|
|
599
|
+
} catch (err) {
|
|
600
|
+
throw CliError.usage(`读不了 ${file}:${rustIoErrorText(err)}`);
|
|
601
|
+
}
|
|
602
|
+
let text;
|
|
603
|
+
try {
|
|
604
|
+
// `fatal: true`:遇到非法 UTF-8 序列直接抛。静默按 UTF-8 解会把中文变成乱码入库,
|
|
605
|
+
// 而"表里全是问号"是客户最难自己查出来的故障。
|
|
606
|
+
text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
607
|
+
} catch {
|
|
608
|
+
throw CliError.usage(`${file} 不是 UTF-8 编码(Excel 默认导出常是 GBK)`).withHint(
|
|
609
|
+
'在 Excel 里「另存为 → CSV UTF-8(逗号分隔)」,或直接传 .xlsx',
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
if (text.startsWith('\uFEFF')) text = text.slice(1);
|
|
613
|
+
const firstLine = text.split('\n')[0] ?? '';
|
|
614
|
+
return parseCsvText(text, sniffDelimiter(firstLine));
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Excel 日期序列号 → `YYYY-MM-DD`。
|
|
619
|
+
*
|
|
620
|
+
* 自己算而不是依赖某个库:我们只需要"年月日",
|
|
621
|
+
* 序列号 1 = 1900-01-01、0 = 1899-12-30 这条定义足够。
|
|
622
|
+
* Excel 把 1900 当闰年(历史 bug),所以序列号 60 = 1900-02-29(假日期),
|
|
623
|
+
* 我们跳过它:`>= 60` 时减 1(于是 61 = 1900-03-01)。
|
|
624
|
+
*/
|
|
625
|
+
export function excelSerialToDate(serial) {
|
|
626
|
+
if (!Number.isFinite(serial) || serial < 1.0 || serial > 2958465.0) return null;
|
|
627
|
+
const daysRaw = Math.trunc(serial);
|
|
628
|
+
const days = daysRaw >= 60 ? daysRaw - 1 : daysRaw;
|
|
629
|
+
const epoch = Date.UTC(1899, 11, 31); // epoch 是 1899-12-31
|
|
630
|
+
const date = new Date(epoch + days * 86400000);
|
|
631
|
+
if (Number.isNaN(date.getTime())) return null;
|
|
632
|
+
const year = date.getUTCFullYear();
|
|
633
|
+
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
|
634
|
+
const day = String(date.getUTCDate()).padStart(2, '0');
|
|
635
|
+
return `${String(year).padStart(4, '0')}-${month}-${day}`;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** `1200.0` → `1200`;`1234.56` → `1234.56`(数字要**去掉浮点尾巴**)。 */
|
|
639
|
+
export function formatFloat(number) {
|
|
640
|
+
if (!Number.isFinite(number)) return '';
|
|
641
|
+
if (Number.isInteger(number)) {
|
|
642
|
+
if (Math.abs(number) < 1e15) return String(number);
|
|
643
|
+
if (Math.abs(number) < 1e21) return BigInt(number).toString();
|
|
644
|
+
}
|
|
645
|
+
return String(number);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// ---------------------------------------------------------------------------
|
|
649
|
+
// 取值 → API 值
|
|
650
|
+
// ---------------------------------------------------------------------------
|
|
651
|
+
/**
|
|
652
|
+
* 把一列的值按列类型转成 API 能收的 JSON 值。
|
|
653
|
+
*
|
|
654
|
+
* 数值一律回**字符串**(服务端 `_parse_decimal` 收字符串,
|
|
655
|
+
* 而 JSON 的 number 是 float64 —— 金额过一遍就会丢精度)。
|
|
656
|
+
*/
|
|
657
|
+
export function coerceValue(column, raw) {
|
|
658
|
+
if (isBlank(raw)) return null;
|
|
659
|
+
switch (column.columnType) {
|
|
660
|
+
case ColumnType.Boolean: {
|
|
661
|
+
const flag = asBool(raw);
|
|
662
|
+
return flag === null ? String(raw).trim() : flag;
|
|
663
|
+
}
|
|
664
|
+
case ColumnType.Date: {
|
|
665
|
+
const date = asDate(raw);
|
|
666
|
+
return date === null ? String(raw).trim() : date;
|
|
667
|
+
}
|
|
668
|
+
case ColumnType.Number:
|
|
669
|
+
case ColumnType.Currency: {
|
|
670
|
+
const number = normalizeNumber(raw);
|
|
671
|
+
return number === null ? String(raw).trim() : number.value;
|
|
672
|
+
}
|
|
673
|
+
default:
|
|
674
|
+
return String(raw);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** 按批切分(每批 ≤ `MAX_BATCH`)。返回 `[[start, end], ...]`。 */
|
|
679
|
+
export function batchIndices(total, batchSize) {
|
|
680
|
+
const size = Math.min(Math.max(Math.trunc(batchSize) || 1, 1), MAX_BATCH);
|
|
681
|
+
const out = [];
|
|
682
|
+
let start = 0;
|
|
683
|
+
while (start < total) {
|
|
684
|
+
const end = Math.min(start + size, total);
|
|
685
|
+
out.push([start, end]);
|
|
686
|
+
start = end;
|
|
687
|
+
}
|
|
688
|
+
return out;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** 字段定义(`POST /tables/{id}/fields` 的请求体)。 */
|
|
692
|
+
export function fieldPayload(column, sortOrder) {
|
|
693
|
+
const payload = {
|
|
694
|
+
semantic_key: column.semanticKey,
|
|
695
|
+
name: column.name,
|
|
696
|
+
type: column.columnType,
|
|
697
|
+
sort_order: sortOrder,
|
|
698
|
+
};
|
|
699
|
+
if (column.columnType === ColumnType.Currency) {
|
|
700
|
+
payload.property = {
|
|
701
|
+
precision: 18,
|
|
702
|
+
scale: Math.max(column.maxScale, 2),
|
|
703
|
+
currency: 'CNY',
|
|
704
|
+
};
|
|
705
|
+
} else if (column.columnType === ColumnType.Number) {
|
|
706
|
+
if (column.maxScale > 0) {
|
|
707
|
+
payload.property = { precision: 38, scale: Math.min(column.maxScale, 10) };
|
|
708
|
+
}
|
|
709
|
+
} else if (column.columnType === ColumnType.Text) {
|
|
710
|
+
// 长文本(备注、单据说明)容易顶到默认 4000。
|
|
711
|
+
payload.property = { max_length: 4000 };
|
|
712
|
+
}
|
|
713
|
+
return payload;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* 文件指纹:**内容**决定(列名 + 类型 + 每一行)—— SHA-256 取前 16 位十六进制。
|
|
718
|
+
*
|
|
719
|
+
* 为什么不是"路径 + 首尾行":`--append` 的幂等键由它算出来,而
|
|
720
|
+
* "每月把同一份导出文件更新后追加到同一张表"正是最常见的用法 ——
|
|
721
|
+
* 只哈希路径与首尾行时,中间若干行变了而指纹不变,服务端会把新数据
|
|
722
|
+
* 当重放**静默丢掉**(数据看起来"导进去了",其实一条没写)。
|
|
723
|
+
*
|
|
724
|
+
* 内容决定指纹的代价:同一份内容重复追加会被当成重放(这正是幂等的本义)。
|
|
725
|
+
*/
|
|
726
|
+
export function fileFingerprint(parsed) {
|
|
727
|
+
const hash = createHash('sha256');
|
|
728
|
+
for (const column of parsed.columns) {
|
|
729
|
+
hash.update(Buffer.from(column.name, 'utf8'));
|
|
730
|
+
hash.update(Buffer.from([0x1f]));
|
|
731
|
+
hash.update(Buffer.from(column.columnType, 'utf8'));
|
|
732
|
+
hash.update(Buffer.from([0x1e]));
|
|
733
|
+
}
|
|
734
|
+
for (const row of parsed.rows) {
|
|
735
|
+
for (const cell of row) {
|
|
736
|
+
hash.update(Buffer.from(String(cell), 'utf8'));
|
|
737
|
+
hash.update(Buffer.from([0x1f]));
|
|
738
|
+
}
|
|
739
|
+
hash.update(Buffer.from([0x1e]));
|
|
740
|
+
}
|
|
741
|
+
return hash.digest('hex').slice(0, 16);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/** 一行记录的 `values`(空值不进 values —— 服务端把 null 当"删掉这个 cell")。 */
|
|
745
|
+
export function rowValues(columns, row) {
|
|
746
|
+
const values = {};
|
|
747
|
+
columns.forEach((item, index) => {
|
|
748
|
+
const raw = row[index] === undefined ? '' : row[index];
|
|
749
|
+
const value = coerceValue(item.column, raw);
|
|
750
|
+
if (value !== null) values[item.writeKey] = value;
|
|
751
|
+
});
|
|
752
|
+
return values;
|
|
753
|
+
}
|