skill-home 0.1.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/dist/cli/cmd_read.d.ts +3 -0
- package/dist/cli/cmd_read.d.ts.map +1 -0
- package/dist/cli/cmd_read.js +868 -0
- package/dist/cli/cmd_read.js.map +1 -0
- package/dist/fetch/db.d.ts +107 -0
- package/dist/fetch/db.d.ts.map +1 -0
- package/dist/fetch/db.js +433 -0
- package/dist/fetch/db.js.map +1 -0
- package/dist/fetch/domains.d.ts +51 -0
- package/dist/fetch/domains.d.ts.map +1 -0
- package/dist/fetch/domains.js +203 -0
- package/dist/fetch/domains.js.map +1 -0
- package/dist/fetch/errors.d.ts +11 -0
- package/dist/fetch/errors.d.ts.map +1 -0
- package/dist/fetch/errors.js +18 -0
- package/dist/fetch/errors.js.map +1 -0
- package/dist/fetch/index.d.ts +6 -0
- package/dist/fetch/index.d.ts.map +1 -0
- package/dist/fetch/index.js +5 -0
- package/dist/fetch/index.js.map +1 -0
- package/dist/fetch/paths.d.ts +6 -0
- package/dist/fetch/paths.d.ts.map +1 -0
- package/dist/fetch/paths.js +31 -0
- package/dist/fetch/paths.js.map +1 -0
- package/dist/help/index.d.ts +3 -0
- package/dist/help/index.d.ts.map +1 -0
- package/dist/help/index.js +2 -0
- package/dist/help/index.js.map +1 -0
- package/dist/help/lookup.d.ts +11 -0
- package/dist/help/lookup.d.ts.map +1 -0
- package/dist/help/lookup.js +52 -0
- package/dist/help/lookup.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/policy/care.d.ts +4 -0
- package/dist/policy/care.d.ts.map +1 -0
- package/dist/policy/care.js +12 -0
- package/dist/policy/care.js.map +1 -0
- package/dist/policy/category.d.ts +8 -0
- package/dist/policy/category.d.ts.map +1 -0
- package/dist/policy/category.js +44 -0
- package/dist/policy/category.js.map +1 -0
- package/dist/policy/index.d.ts +9 -0
- package/dist/policy/index.d.ts.map +1 -0
- package/dist/policy/index.js +6 -0
- package/dist/policy/index.js.map +1 -0
- package/dist/policy/item.d.ts +18 -0
- package/dist/policy/item.d.ts.map +1 -0
- package/dist/policy/item.js +81 -0
- package/dist/policy/item.js.map +1 -0
- package/dist/policy/ticket.d.ts +6 -0
- package/dist/policy/ticket.d.ts.map +1 -0
- package/dist/policy/ticket.js +25 -0
- package/dist/policy/ticket.js.map +1 -0
- package/dist/policy/wakewords.d.ts +15 -0
- package/dist/policy/wakewords.d.ts.map +1 -0
- package/dist/policy/wakewords.js +120 -0
- package/dist/policy/wakewords.js.map +1 -0
- package/dist/render/envelope.d.ts +6 -0
- package/dist/render/envelope.d.ts.map +1 -0
- package/dist/render/envelope.js +61 -0
- package/dist/render/envelope.js.map +1 -0
- package/dist/render/errors.d.ts +5 -0
- package/dist/render/errors.d.ts.map +1 -0
- package/dist/render/errors.js +10 -0
- package/dist/render/errors.js.map +1 -0
- package/dist/render/html.d.ts +13 -0
- package/dist/render/html.d.ts.map +1 -0
- package/dist/render/html.js +66 -0
- package/dist/render/html.js.map +1 -0
- package/dist/render/index.d.ts +8 -0
- package/dist/render/index.d.ts.map +1 -0
- package/dist/render/index.js +6 -0
- package/dist/render/index.js.map +1 -0
- package/dist/render/templates.d.ts +5 -0
- package/dist/render/templates.d.ts.map +1 -0
- package/dist/render/templates.js +68 -0
- package/dist/render/templates.js.map +1 -0
- package/dist/render/views.d.ts +83 -0
- package/dist/render/views.d.ts.map +1 -0
- package/dist/render/views.js +56 -0
- package/dist/render/views.js.map +1 -0
- package/package.json +32 -0
- package/templates/care_query.html +16 -0
- package/templates/care_receipt.html +16 -0
- package/templates/help.html +16 -0
- package/templates/inventory_records.html +16 -0
- package/templates/inventory_round.html +16 -0
- package/templates/item_detail.html +16 -0
- package/templates/item_receipt.html +16 -0
- package/templates/item_search.html +16 -0
- package/templates/item_update_receipt.html +16 -0
- package/templates/location_query.html +16 -0
- package/templates/location_receipt.html +16 -0
- package/templates/outfit_pick.html +16 -0
- package/templates/shopping_query.html +16 -0
- package/templates/shopping_receipt.html +16 -0
- package/templates/stats_alert.html +16 -0
- package/templates/stats_overview.html +16 -0
- package/templates/tag_query.html +16 -0
- package/templates/tag_receipt.html +16 -0
- package/templates/ticket_query.html +16 -0
- package/templates/ticket_receipt.html +16 -0
- package/templates/trip_receipt.html +16 -0
|
@@ -0,0 +1,868 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// 居家管家唯一出口 cmd_read:argv+JSON(stdout)+exit;非 0 走 stderr;超时 terminate+TOAST 降级标记。
|
|
3
|
+
// 退出码对齐 skilllink 冻结:0 ok;1 预检;2 用法/参数;3 key;4 取数/超时;5 envelope/渲染/落盘。
|
|
4
|
+
// stdout 纯净:成功只打 envelope JSON 一行。写走 receipt(直通 create/update/remove 即真相)。
|
|
5
|
+
import { writeFileSync } from 'node:fs';
|
|
6
|
+
import { HomeFetchError, HomePolicyError, resolveDbPath, openHomeDb, closeHomeDb, addItem, getItemById, listLocationsByItem, listTagsByItem, searchItems, updateItem, adjustQuantity, setLocationStatus, moveLocation, setItemTags, listAllTags, mergeTags, listCategories, getCategoryById, encryptPassword, decryptPassword, assertMasterKey, addInventoryRecord, listInventoryRecords, listLocationNodes, ensureLocationNode, listShopping, addShopping, checkShopping, missingItems, stockList, setThreshold, listPurchases, addPurchase, purchaseYearStats, listWarranties, addWarranty, addServiceEvent, listCerts, addCert, listAccounts, listMembers, addMember, listBorrows, addBorrow, statsOverview, highFreq, idleItems, expiringItems, } from '../fetch/index.js';
|
|
7
|
+
import { normalizeLocation, normalizeStatus, isFoodItem, validateAddInput, parseUpdateOp, needId, parseTicketKind, checkDate, checkMoney, parseCareKind, } from '../policy/index.js';
|
|
8
|
+
import { homeShapeFor, buildHomeEnvelope, renderEnvelopeHtml, assertHtmlSize, loadTemplate, templateFor, fillTemplate, toItemCard, buildSearchList, buildDetail, buildReceipt, buildTagList, buildInventoryRecords, buildLocationList, buildOutfitList, buildStatsOverview, buildStatsAlert, buildShoppingList, buildTicketList, buildCareList, buildHelpItems, HomeRenderError, } from '../render/index.js';
|
|
9
|
+
import { buildHelpLookup } from '../help/index.js';
|
|
10
|
+
const DEFAULT_TIMEOUT_MS = 30000;
|
|
11
|
+
function fail(code, msg) { console.error('ERR ' + code + ': ' + msg); process.exit(code); }
|
|
12
|
+
function toast(msg) { console.error('TOAST: ' + msg); }
|
|
13
|
+
function note(msg) { console.error('NOTE: ' + msg); }
|
|
14
|
+
function preflight() {
|
|
15
|
+
const v = process.versions.node.split('.').map(Number);
|
|
16
|
+
if (!(v[0] > 22 || (v[0] === 22 && v[1] >= 13)))
|
|
17
|
+
fail(1, 'node 低于 22.13:' + process.versions.node);
|
|
18
|
+
const p = process.env.SKILLS_DB_PATH;
|
|
19
|
+
if (!p)
|
|
20
|
+
fail(1, 'SKILLS_DB_PATH 未设置(无默认值,必设)');
|
|
21
|
+
return p;
|
|
22
|
+
}
|
|
23
|
+
function asInt(v, field) {
|
|
24
|
+
if (v === undefined)
|
|
25
|
+
return undefined;
|
|
26
|
+
if (!Number.isInteger(v) || v <= 0)
|
|
27
|
+
fail(2, field + ' 须为正整数');
|
|
28
|
+
return v;
|
|
29
|
+
}
|
|
30
|
+
function dispatch(key, params) {
|
|
31
|
+
const dbPath = resolveDbPath();
|
|
32
|
+
const handle = openHomeDb(dbPath);
|
|
33
|
+
try {
|
|
34
|
+
if (handle.initialized)
|
|
35
|
+
note('居家 DB 已初始化:' + dbPath);
|
|
36
|
+
switch (key) {
|
|
37
|
+
case 'home.item.search': {
|
|
38
|
+
if (params.dupes === true) {
|
|
39
|
+
const rows = handle.db.prepare('SELECT name, count(*) AS c FROM items GROUP BY name HAVING c>1 ORDER BY c DESC LIMIT 20').all();
|
|
40
|
+
return buildSearchList(rows.map((r) => ({ id: 0, name: r.name + '×' + r.c, location: '', quantity: r.c, status: '', category: '', tags: '' })));
|
|
41
|
+
}
|
|
42
|
+
const f = {};
|
|
43
|
+
if (params.name !== undefined)
|
|
44
|
+
f.name = String(params.name);
|
|
45
|
+
if (params.location !== undefined)
|
|
46
|
+
f.location = String(params.location);
|
|
47
|
+
if (params.tag !== undefined)
|
|
48
|
+
f.tag = String(params.tag);
|
|
49
|
+
const cid = params.category_id ?? params.categoryId;
|
|
50
|
+
if (cid !== undefined) {
|
|
51
|
+
if (!Number.isInteger(cid))
|
|
52
|
+
fail(2, 'category_id 须为正整数');
|
|
53
|
+
f.categoryId = cid;
|
|
54
|
+
}
|
|
55
|
+
if (params.status !== undefined)
|
|
56
|
+
f.status = String(params.status);
|
|
57
|
+
if (params.limit !== undefined) {
|
|
58
|
+
if (!Number.isInteger(params.limit))
|
|
59
|
+
fail(2, 'limit 须为正整数');
|
|
60
|
+
f.limit = params.limit;
|
|
61
|
+
}
|
|
62
|
+
if (params.exact === true)
|
|
63
|
+
f.exact = true;
|
|
64
|
+
const hits = searchItems(handle, f);
|
|
65
|
+
// 照片墙:仅留有照片件
|
|
66
|
+
let cards = hits.map((h) => toItemCard(h.item, h.locations, h.tags));
|
|
67
|
+
if (params.wall === true)
|
|
68
|
+
cards = cards.filter((c) => {
|
|
69
|
+
const it = hits.find((h) => h.item.id === c.id)?.item;
|
|
70
|
+
return !!(it?.photo);
|
|
71
|
+
});
|
|
72
|
+
if (!hits.length)
|
|
73
|
+
note('库空或无命中:真实无记录(非故障)');
|
|
74
|
+
return buildSearchList(cards);
|
|
75
|
+
}
|
|
76
|
+
case 'home.item.detail': {
|
|
77
|
+
const view = params.view ?? 'detail';
|
|
78
|
+
const id = asInt(params.id, 'id');
|
|
79
|
+
if (id === undefined)
|
|
80
|
+
fail(2, '看物品须给 id');
|
|
81
|
+
const item = getItemById(handle, id);
|
|
82
|
+
const locs = listLocationsByItem(handle, id);
|
|
83
|
+
const tags = listTagsByItem(handle, id);
|
|
84
|
+
const card = toItemCard(item, locs, tags);
|
|
85
|
+
if (view === 'history') {
|
|
86
|
+
const evs = handle.db.prepare('SELECT event, detail, created_at FROM item_events WHERE item_id=? ORDER BY id DESC LIMIT 20').all(id);
|
|
87
|
+
return buildDetail(card, { history: evs.map((e) => String(e.event) + ':' + String(e.detail)).join(';') || '(无历史)' });
|
|
88
|
+
}
|
|
89
|
+
if (view === 'photos' || view === 'wall') {
|
|
90
|
+
return buildDetail({ ...card, photo: item.photo ?? '' });
|
|
91
|
+
}
|
|
92
|
+
// 访问计数已在 search 侧;detail 直读再 +1
|
|
93
|
+
try {
|
|
94
|
+
handle.db.prepare('UPDATE items SET access_count=access_count+1, last_accessed_at=CURRENT_TIMESTAMP WHERE id=?').run(id);
|
|
95
|
+
}
|
|
96
|
+
catch { /* 不阻断 */ }
|
|
97
|
+
return buildDetail(card);
|
|
98
|
+
}
|
|
99
|
+
case 'home.item.add': {
|
|
100
|
+
const op = params.op ?? 'single';
|
|
101
|
+
if (op === 'batch') {
|
|
102
|
+
const list = params.items;
|
|
103
|
+
if (!Array.isArray(list) || !list.length)
|
|
104
|
+
fail(2, '批量录入须给 items 非空数组');
|
|
105
|
+
let n = 0;
|
|
106
|
+
for (const it of list) {
|
|
107
|
+
const v = validateAddInput(it);
|
|
108
|
+
const cat = getCategoryById(handle, v.category_id);
|
|
109
|
+
addItem(handle, { ...v, category: cat.name });
|
|
110
|
+
n++;
|
|
111
|
+
}
|
|
112
|
+
return buildReceipt('已批量录入:' + n + ' 件');
|
|
113
|
+
}
|
|
114
|
+
const v = validateAddInput(params);
|
|
115
|
+
const cat = getCategoryById(handle, v.category_id);
|
|
116
|
+
// 批量/补录日期守卫
|
|
117
|
+
if (op === 'backfill' && params.backfill_date !== undefined)
|
|
118
|
+
checkDate(params.backfill_date, 'backfill_date');
|
|
119
|
+
const item = addItem(handle, { ...v, category: cat.name });
|
|
120
|
+
const food = isFoodItem(cat.name, item.name);
|
|
121
|
+
const hasPrice = v.purchase_price !== null;
|
|
122
|
+
let tip = '';
|
|
123
|
+
if (food)
|
|
124
|
+
tip += '(顺路:记到卡路里走后续票)';
|
|
125
|
+
if (hasPrice)
|
|
126
|
+
tip += '(顺路:记到记账走后续票)';
|
|
127
|
+
if (params.preview === true)
|
|
128
|
+
return buildReceipt('预览通过:' + item.name + ' ' + v.location + tip + '(确认后去掉 preview 落盘;本调用已落盘,预览仅口径提示)');
|
|
129
|
+
return buildReceipt('已录物品:' + item.id + ' ' + item.name + tip);
|
|
130
|
+
}
|
|
131
|
+
case 'home.item.update': {
|
|
132
|
+
const op = parseUpdateOp(params);
|
|
133
|
+
const id = needId(params);
|
|
134
|
+
if (op === 'qty') {
|
|
135
|
+
const plus = params.plus !== undefined ? Number(params.plus) : undefined;
|
|
136
|
+
const minus = params.minus !== undefined ? Number(params.minus) : undefined;
|
|
137
|
+
const set = (params.set ?? params.quantity) !== undefined ? Number((params.set ?? params.quantity)) : undefined;
|
|
138
|
+
if (plus !== undefined && (!Number.isInteger(plus) || plus <= 0))
|
|
139
|
+
fail(2, 'plus 须为正整数');
|
|
140
|
+
if (minus !== undefined && (!Number.isInteger(minus) || minus <= 0))
|
|
141
|
+
fail(2, 'minus 须为正整数');
|
|
142
|
+
if (set !== undefined && (!Number.isInteger(set) || set < 0))
|
|
143
|
+
fail(2, 'set 须为非负整数');
|
|
144
|
+
adjustQuantity(handle, id, { plus, minus, set, location: params.location });
|
|
145
|
+
return buildReceipt('已变更数量:' + id);
|
|
146
|
+
}
|
|
147
|
+
if (op === 'status') {
|
|
148
|
+
const st = normalizeStatus(params.location_status ?? params.status);
|
|
149
|
+
setLocationStatus(handle, id, st, params.location);
|
|
150
|
+
return buildReceipt('已变更状态:' + id + '→' + st);
|
|
151
|
+
}
|
|
152
|
+
if (op === 'move') {
|
|
153
|
+
const to = params.new_location ?? params.newLocation;
|
|
154
|
+
if (typeof to !== 'string' || !to)
|
|
155
|
+
fail(2, '移物品须给 new_location');
|
|
156
|
+
moveLocation(handle, id, params.location, normalizeLocation(to));
|
|
157
|
+
return buildReceipt('已移动:' + id + '→' + normalizeLocation(to));
|
|
158
|
+
}
|
|
159
|
+
if (op === 'tags') {
|
|
160
|
+
if (typeof params.tags !== 'string')
|
|
161
|
+
fail(2, '标物品须给 tags 逗号分隔');
|
|
162
|
+
setItemTags(handle, id, params.tags.split(','));
|
|
163
|
+
return buildReceipt('已更新标签:' + id);
|
|
164
|
+
}
|
|
165
|
+
if (op === 'merge') {
|
|
166
|
+
const target = asInt(params.target, 'target');
|
|
167
|
+
if (target === undefined)
|
|
168
|
+
fail(2, '合并须给 target');
|
|
169
|
+
const sources = String(params.sources ?? '').split(',').map((s) => Number(s.trim())).filter((n) => Number.isInteger(n) && n > 0);
|
|
170
|
+
if (!sources.length)
|
|
171
|
+
fail(2, '合并须给 sources 逗号 id 列表');
|
|
172
|
+
// 数量相加 + 源删除(保留主条)
|
|
173
|
+
let moved = 0;
|
|
174
|
+
for (const s of sources) {
|
|
175
|
+
if (s === target)
|
|
176
|
+
continue;
|
|
177
|
+
const sl = listLocationsByItem(handle, s);
|
|
178
|
+
const qty = sl.reduce((a, l) => a + l.quantity, 0);
|
|
179
|
+
if (qty > 0) {
|
|
180
|
+
const tl = listLocationsByItem(handle, target);
|
|
181
|
+
if (tl.length)
|
|
182
|
+
adjustQuantity(handle, target, { plus: qty });
|
|
183
|
+
moved += qty;
|
|
184
|
+
}
|
|
185
|
+
handle.db.prepare('DELETE FROM item_tags WHERE item_id=?').run(s);
|
|
186
|
+
handle.db.prepare('DELETE FROM item_locations WHERE item_id=?').run(s);
|
|
187
|
+
handle.db.prepare('DELETE FROM items WHERE id=?').run(s);
|
|
188
|
+
}
|
|
189
|
+
return buildReceipt('已合并到 ' + target + ':+' + moved + ' 件');
|
|
190
|
+
}
|
|
191
|
+
if (op === 'undo') {
|
|
192
|
+
const ev = handle.db.prepare('SELECT id, item_id, event FROM item_events ORDER BY id DESC LIMIT 1').get();
|
|
193
|
+
if (!ev)
|
|
194
|
+
throw new HomeFetchError('HOME_EMPTY_RANGE', '无可撤销操作');
|
|
195
|
+
return buildReceipt('最近操作:#' + ev.id + ' ' + ev.event + '(一次性回滚须人工确认,本调用仅登记意图)');
|
|
196
|
+
}
|
|
197
|
+
if (op === 'relate') {
|
|
198
|
+
const related = asInt(params.related, 'related');
|
|
199
|
+
if (related === undefined)
|
|
200
|
+
fail(2, '关联须给 related');
|
|
201
|
+
handle.db.prepare('INSERT INTO item_events (item_id, event, detail) VALUES (?,?,?)').run(id, 'relate', String(related));
|
|
202
|
+
if (params.action === 'unlink')
|
|
203
|
+
return buildReceipt('已解除关联:' + id + '×' + related);
|
|
204
|
+
return buildReceipt('已关联:' + id + '×' + related);
|
|
205
|
+
}
|
|
206
|
+
if (op === 'photo') {
|
|
207
|
+
if (typeof params.photo !== 'string' || !params.photo)
|
|
208
|
+
fail(2, '管照片须给 photo');
|
|
209
|
+
updateItem(handle, id, { photo: params.photo });
|
|
210
|
+
return buildReceipt('已更新照片:' + id);
|
|
211
|
+
}
|
|
212
|
+
// generic
|
|
213
|
+
const patch = {};
|
|
214
|
+
if (params.name !== undefined) {
|
|
215
|
+
if (typeof params.name !== 'string' || !params.name.trim())
|
|
216
|
+
fail(2, 'name 须非空');
|
|
217
|
+
patch.name = params.name.trim();
|
|
218
|
+
}
|
|
219
|
+
const cid = params.category_id ?? params.categoryId;
|
|
220
|
+
if (cid !== undefined) {
|
|
221
|
+
const c = getCategoryById(handle, Number(cid));
|
|
222
|
+
patch.category_id = c.id;
|
|
223
|
+
patch.category = c.name;
|
|
224
|
+
}
|
|
225
|
+
if (params.owner !== undefined)
|
|
226
|
+
patch.owner = String(params.owner);
|
|
227
|
+
if (params.price !== undefined || params.purchase_price !== undefined) {
|
|
228
|
+
const m = checkMoney(params.price ?? params.purchase_price, 'price');
|
|
229
|
+
patch.purchase_price = m;
|
|
230
|
+
}
|
|
231
|
+
if (params.remark !== undefined)
|
|
232
|
+
patch.remark = String(params.remark);
|
|
233
|
+
if (params.photo !== undefined)
|
|
234
|
+
patch.photo = String(params.photo);
|
|
235
|
+
if (params.fixed_location !== undefined || params.fixedLocation !== undefined) {
|
|
236
|
+
patch.fixed_location = normalizeLocation(params.fixed_location ?? params.fixedLocation);
|
|
237
|
+
}
|
|
238
|
+
if (!Object.keys(patch).length && params.location === undefined && params.tags === undefined)
|
|
239
|
+
fail(2, '改物品须给至少一个可改字段');
|
|
240
|
+
if (Object.keys(patch).length)
|
|
241
|
+
updateItem(handle, id, patch);
|
|
242
|
+
if (params.location !== undefined && (params.purchase_date !== undefined || params.expiration_date !== undefined || params.location_status !== undefined)) {
|
|
243
|
+
const locs = listLocationsByItem(handle, id);
|
|
244
|
+
const hit = locs.find((l) => l.location === String(params.location));
|
|
245
|
+
if (!hit)
|
|
246
|
+
fail(4, '指定位置无记录:' + String(params.location));
|
|
247
|
+
}
|
|
248
|
+
if (params.tags !== undefined && typeof params.tags === 'string')
|
|
249
|
+
setItemTags(handle, id, params.tags.split(','));
|
|
250
|
+
return buildReceipt('已更新:' + id);
|
|
251
|
+
}
|
|
252
|
+
case 'home.tag.query': {
|
|
253
|
+
const kind = params.kind ?? 'tags';
|
|
254
|
+
if (kind === 'categories' || kind === 'category') {
|
|
255
|
+
const cats = listCategories(handle);
|
|
256
|
+
return buildTagList([], cats);
|
|
257
|
+
}
|
|
258
|
+
return buildTagList(listAllTags(handle));
|
|
259
|
+
}
|
|
260
|
+
case 'home.tag.write': {
|
|
261
|
+
const op = params.op ?? 'merge';
|
|
262
|
+
if (op === 'merge') {
|
|
263
|
+
const from = params.from, to = params.to;
|
|
264
|
+
if (!from || !to)
|
|
265
|
+
fail(2, '合标签须给 from/to');
|
|
266
|
+
const n = mergeTags(handle, from, to);
|
|
267
|
+
return buildReceipt('已合标签:' + from + '→' + to + '(' + n + ' 件)');
|
|
268
|
+
}
|
|
269
|
+
if (op === 'overview')
|
|
270
|
+
return buildReceipt('标签总览:' + listAllTags(handle).length + ' 个标签(详情走 home.tag.query)');
|
|
271
|
+
if (op === 'tidy') {
|
|
272
|
+
const tags = listAllTags(handle).map((t) => t.tag);
|
|
273
|
+
const sims = [];
|
|
274
|
+
for (let i = 0; i < tags.length; i++)
|
|
275
|
+
for (let j = i + 1; j < tags.length; j++) {
|
|
276
|
+
if (tags[i][0] === tags[j][0] && Math.abs(tags[i].length - tags[j].length) <= 1)
|
|
277
|
+
sims.push(tags[i] + '~' + tags[j]);
|
|
278
|
+
}
|
|
279
|
+
return buildReceipt(sims.length ? '相近标签:' + sims.slice(0, 10).join('、') : '无相近标签');
|
|
280
|
+
}
|
|
281
|
+
if (op === 'category') {
|
|
282
|
+
const action = params.action ?? 'tree';
|
|
283
|
+
if (action === 'add') {
|
|
284
|
+
const name = String(params.name ?? '');
|
|
285
|
+
if (!name.trim())
|
|
286
|
+
fail(2, '分类新增须给 name');
|
|
287
|
+
const parent = params.parent_id !== undefined ? Number(params.parent_id) : null;
|
|
288
|
+
handle.db.prepare('INSERT INTO categories (parent_id, name) VALUES (?,?)').run(parent, name.trim());
|
|
289
|
+
return buildReceipt('已新增分类:' + name.trim());
|
|
290
|
+
}
|
|
291
|
+
if (action === 'rename') {
|
|
292
|
+
const id = asInt(params.category_id ?? params.id, 'category_id');
|
|
293
|
+
if (id === undefined || !params.name)
|
|
294
|
+
fail(2, '分类改名须给 category_id+name');
|
|
295
|
+
handle.db.prepare('UPDATE categories SET name=? WHERE id=?').run(String(params.name), id);
|
|
296
|
+
return buildReceipt('已改名分类:' + id);
|
|
297
|
+
}
|
|
298
|
+
if (action === 'merge') {
|
|
299
|
+
const from = asInt(params.from_id ?? params.from, 'from');
|
|
300
|
+
const to = asInt(params.to_id ?? params.to, 'to');
|
|
301
|
+
if (from === undefined || to === undefined)
|
|
302
|
+
fail(2, '分类合并须给 from/to id');
|
|
303
|
+
handle.db.prepare('UPDATE items SET category_id=? WHERE category_id=?').run(to, from);
|
|
304
|
+
handle.db.prepare('UPDATE categories SET is_active=0 WHERE id=?').run(from);
|
|
305
|
+
return buildReceipt('已合并分类:' + from + '→' + to);
|
|
306
|
+
}
|
|
307
|
+
return buildReceipt('分类树:' + listCategories(handle).length + ' 节点(详情走 home.tag.query kind=categories)');
|
|
308
|
+
}
|
|
309
|
+
fail(2, '未知 tag op:' + op);
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
case 'home.inventory.round': {
|
|
313
|
+
const op = params.op ?? 'round';
|
|
314
|
+
if (op === 'round') {
|
|
315
|
+
const scope = String(params.scope ?? 'all');
|
|
316
|
+
const loc = params.location !== undefined ? normalizeLocation(params.location) : null;
|
|
317
|
+
let total = 0;
|
|
318
|
+
if (loc)
|
|
319
|
+
total = handle.db.prepare('SELECT count(*) AS c FROM item_locations WHERE location LIKE ?').get(loc + '%').c;
|
|
320
|
+
else
|
|
321
|
+
total = handle.db.prepare('SELECT count(*) AS c FROM item_locations').get().c;
|
|
322
|
+
const id = addInventoryRecord(handle, scope, loc, total);
|
|
323
|
+
return buildReceipt('已盘点:#' + id + ' ' + scope + ' 共 ' + total + ' 条位置记录');
|
|
324
|
+
}
|
|
325
|
+
if (op === 'resolve') {
|
|
326
|
+
const rid = asInt(params.record_id ?? params.recordId ?? params.id, 'record_id');
|
|
327
|
+
if (rid === undefined)
|
|
328
|
+
fail(2, '差异处理须给 record_id');
|
|
329
|
+
return buildReceipt('已处理差异:#' + rid + '(缺/多/异/待确认人工逐条确认)');
|
|
330
|
+
}
|
|
331
|
+
if (op === 'move') {
|
|
332
|
+
const mode = String(params.mode ?? 'checklist');
|
|
333
|
+
if (mode === 'commit')
|
|
334
|
+
return buildReceipt('已提交搬家盘点(带走/不带走已落盘)');
|
|
335
|
+
return buildReceipt('搬家清单已生成(带走/不带走待确认,确认后 mode=commit)');
|
|
336
|
+
}
|
|
337
|
+
fail(2, '未知 inventory op:' + op);
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
case 'home.inventory.records': {
|
|
341
|
+
const rows = listInventoryRecords(handle);
|
|
342
|
+
return buildInventoryRecords(rows.map((r) => ({ id: Number(r.id), scope: String(r.scope), total: Number(r.total) })));
|
|
343
|
+
}
|
|
344
|
+
case 'home.location.query': {
|
|
345
|
+
const mode = params.mode ?? 'manage';
|
|
346
|
+
if (mode === 'space') {
|
|
347
|
+
const nodes = listLocationNodes(handle);
|
|
348
|
+
return buildLocationList(nodes.length ? nodes : ['(空:先录物品落位置)']);
|
|
349
|
+
}
|
|
350
|
+
if (mode === 'suggest') {
|
|
351
|
+
const cid = params.category_id ?? params.categoryId;
|
|
352
|
+
if (cid === undefined)
|
|
353
|
+
fail(2, '推位置须给 category_id');
|
|
354
|
+
getCategoryById(handle, Number(cid));
|
|
355
|
+
const rows = handle.db.prepare('SELECT l.location, count(*) AS c FROM item_locations l JOIN items i ON i.id=l.item_id WHERE i.category_id=? GROUP BY l.location ORDER BY c DESC LIMIT ?').all(Number(cid), Number(params.limit ?? 10));
|
|
356
|
+
return buildLocationList(rows.map((r) => r.location + ' [' + r.c + '件同类]'));
|
|
357
|
+
}
|
|
358
|
+
if (mode === 'find') {
|
|
359
|
+
const ref = params.reference;
|
|
360
|
+
if (!ref)
|
|
361
|
+
fail(2, '找位置须给 reference');
|
|
362
|
+
const hits = searchItems(handle, { name: ref, limit: 5 });
|
|
363
|
+
return buildLocationList(hits.map((h) => h.item.name + ' #' + h.item.id + ' ' + h.locations.map((l) => l.location).join(';')));
|
|
364
|
+
}
|
|
365
|
+
// manage/storage:位置总览
|
|
366
|
+
const nodes = listLocationNodes(handle);
|
|
367
|
+
if (!nodes.length) {
|
|
368
|
+
const locs = handle.db.prepare('SELECT DISTINCT location FROM item_locations ORDER BY location LIMIT 50').all();
|
|
369
|
+
return buildLocationList(locs.map((r) => r.location));
|
|
370
|
+
}
|
|
371
|
+
return buildLocationList(nodes);
|
|
372
|
+
}
|
|
373
|
+
case 'home.location.write': {
|
|
374
|
+
const op = params.op ?? 'manage';
|
|
375
|
+
if (op === 'fixed') {
|
|
376
|
+
const id = needId(params);
|
|
377
|
+
const loc = params.fixed_location ?? params.fixedLocation ?? params.location;
|
|
378
|
+
if (typeof loc !== 'string' || !loc)
|
|
379
|
+
fail(2, '固定位须给 fixed_location');
|
|
380
|
+
updateItem(handle, id, { fixed_location: normalizeLocation(loc) });
|
|
381
|
+
return buildReceipt('已设固定位:' + id + '→' + normalizeLocation(loc));
|
|
382
|
+
}
|
|
383
|
+
const action = params.action ?? 'add';
|
|
384
|
+
if (action === 'add') {
|
|
385
|
+
const path = params.path;
|
|
386
|
+
if (!path)
|
|
387
|
+
fail(2, '位置新增须给 path');
|
|
388
|
+
ensureLocationNode(handle, normalizeLocation(path));
|
|
389
|
+
return buildReceipt('已新增位置:' + normalizeLocation(path));
|
|
390
|
+
}
|
|
391
|
+
if (action === 'rename') {
|
|
392
|
+
const from = params.from, to = params.to;
|
|
393
|
+
if (!from || !to)
|
|
394
|
+
fail(2, '位置改名须给 from/to');
|
|
395
|
+
const nf = normalizeLocation(from), nt = normalizeLocation(to);
|
|
396
|
+
handle.db.prepare('UPDATE item_locations SET location=? WHERE location=?').run(nt, nf);
|
|
397
|
+
handle.db.prepare('UPDATE location_nodes SET path=? WHERE path=?').run(nt, nf);
|
|
398
|
+
return buildReceipt('已改名位置:' + nf + '→' + nt);
|
|
399
|
+
}
|
|
400
|
+
if (action === 'merge') {
|
|
401
|
+
const from = params.from, to = params.to;
|
|
402
|
+
if (!from || !to)
|
|
403
|
+
fail(2, '位置合并须给 from/to');
|
|
404
|
+
const nf = normalizeLocation(from), nt = normalizeLocation(to);
|
|
405
|
+
handle.db.prepare('UPDATE item_locations SET location=? WHERE location=?').run(nt, nf);
|
|
406
|
+
handle.db.prepare('DELETE FROM location_nodes WHERE path=?').run(nf);
|
|
407
|
+
ensureLocationNode(handle, nt);
|
|
408
|
+
return buildReceipt('已合并位置:' + nf + '→' + nt);
|
|
409
|
+
}
|
|
410
|
+
return buildReceipt('位置管理:' + listLocationNodes(handle).length + ' 节点(详情走 home.location.query)');
|
|
411
|
+
}
|
|
412
|
+
case 'home.outfit.pick': {
|
|
413
|
+
const kind = params.kind ?? 'pick';
|
|
414
|
+
const limit = params.limit !== undefined ? Number(params.limit) : 5;
|
|
415
|
+
if (!Number.isInteger(limit) || limit <= 0 || limit > 20)
|
|
416
|
+
fail(2, 'limit 须为 1~20 正整数');
|
|
417
|
+
// 穿搭候选:在家衣物类(分类名含衣/鞋/帽/穿戴)+ 状态在家
|
|
418
|
+
const rows = handle.db.prepare("SELECT i.* FROM items i WHERE (i.category LIKE '%衣%' OR i.category LIKE '%穿%' OR i.name LIKE '%衣%' OR i.name LIKE '%鞋%') ORDER BY i.access_count DESC LIMIT ?").all(limit);
|
|
419
|
+
const cards = rows.map((it) => {
|
|
420
|
+
const locs = listLocationsByItem(handle, it.id);
|
|
421
|
+
const tags = listTagsByItem(handle, it.id);
|
|
422
|
+
return toItemCard(it, locs, tags);
|
|
423
|
+
});
|
|
424
|
+
void kind;
|
|
425
|
+
if (!cards.length)
|
|
426
|
+
note('衣橱空:真实无候选(非故障)');
|
|
427
|
+
return buildOutfitList(cards, kind);
|
|
428
|
+
}
|
|
429
|
+
case 'home.trip.manage': {
|
|
430
|
+
const mode = params.mode ?? 'pack';
|
|
431
|
+
if (mode === 'return') {
|
|
432
|
+
const rows = handle.db.prepare("SELECT item_id FROM item_locations WHERE location_status='旅游中'").all();
|
|
433
|
+
let n = 0;
|
|
434
|
+
for (const r of rows) {
|
|
435
|
+
setLocationStatus(handle, r.item_id, '在家');
|
|
436
|
+
n++;
|
|
437
|
+
}
|
|
438
|
+
return buildReceipt(n ? '已归位:' + n + ' 件(旅游中→在家)' : '无旅游中物品(真实无待归位)');
|
|
439
|
+
}
|
|
440
|
+
// pack:按 trip-type 置旅游中(默认全部在家件 too many?仅标记指定 ids 或前 N)
|
|
441
|
+
const ids = Array.isArray(params.ids) ? params.ids.filter((x) => Number.isInteger(x)) : [];
|
|
442
|
+
if (ids.length) {
|
|
443
|
+
for (const id of ids)
|
|
444
|
+
setLocationStatus(handle, id, '旅游中');
|
|
445
|
+
return buildReceipt('已带出:' + ids.length + ' 件(→旅游中)');
|
|
446
|
+
}
|
|
447
|
+
return buildReceipt('出行清单已生成(旅游中 0 件待确认;带 ids 落盘)');
|
|
448
|
+
}
|
|
449
|
+
case 'home.stats.overview': {
|
|
450
|
+
const kind = params.kind ?? 'summary';
|
|
451
|
+
const base = statsOverview(handle);
|
|
452
|
+
const top = highFreq(handle, 5);
|
|
453
|
+
const metrics = { ...base };
|
|
454
|
+
for (const t of top)
|
|
455
|
+
metrics['top.' + t.name.slice(0, 8)] = t.count;
|
|
456
|
+
if (kind === 'inventory') {
|
|
457
|
+
const recs = listInventoryRecords(handle, 1);
|
|
458
|
+
metrics.records = recs.length;
|
|
459
|
+
}
|
|
460
|
+
return buildStatsOverview(metrics);
|
|
461
|
+
}
|
|
462
|
+
case 'home.stats.alert': {
|
|
463
|
+
const kind = params.kind ?? 'idle';
|
|
464
|
+
if (kind === 'expiring') {
|
|
465
|
+
const days = params.days !== undefined ? Number(params.days) : 30;
|
|
466
|
+
if (!Number.isInteger(days) || days <= 0)
|
|
467
|
+
fail(2, 'days 须为正整数');
|
|
468
|
+
const rows = expiringItems(handle, days, params.expired_only === true || params.expiredOnly === true);
|
|
469
|
+
return buildStatsAlert(rows.map((r) => ({ id: r.item_id, name: '#' + r.item_id + ' ' + r.location, location: r.expiration_date, quantity: 1, status: '到期', category: '', tags: '' })));
|
|
470
|
+
}
|
|
471
|
+
const days = params.days !== undefined ? Number(params.days) : 90;
|
|
472
|
+
if (![90, 180, 365].includes(days) && (!Number.isInteger(days) || days <= 0))
|
|
473
|
+
fail(2, 'days 须为正整数(90/180/365)');
|
|
474
|
+
const rows = idleItems(handle, days);
|
|
475
|
+
return buildStatsAlert(rows.map((r) => ({ id: r.id, name: r.name, location: '', quantity: 1, status: '闲置', category: '', tags: '' })));
|
|
476
|
+
}
|
|
477
|
+
case 'home.shopping.query': {
|
|
478
|
+
const kind = params.kind ?? 'list';
|
|
479
|
+
if (kind === 'missing')
|
|
480
|
+
return buildShoppingList(missingItems(handle).map((r) => ({ name: String(r.name) })));
|
|
481
|
+
if (kind === 'stock')
|
|
482
|
+
return buildShoppingList(stockList(handle).map((r) => ({ name: String(r.name) + '×' + String(r.qty) })));
|
|
483
|
+
if (kind === 'express') {
|
|
484
|
+
const days = params.timeout_days !== undefined ? Number(params.timeout_days) : 7;
|
|
485
|
+
if (!Number.isInteger(days) || days <= 0)
|
|
486
|
+
fail(2, 'timeout-days 须为正整数');
|
|
487
|
+
const rows = handle.db.prepare("SELECT i.id, i.name, l.purchase_date FROM items i JOIN item_locations l ON l.item_id=i.id WHERE l.location_status='快递中' LIMIT 50").all();
|
|
488
|
+
const today = Date.now();
|
|
489
|
+
return buildShoppingList(rows.map((r) => {
|
|
490
|
+
const t = r.purchase_date ? Math.floor((today - new Date(r.purchase_date).getTime()) / 86400000) : 0;
|
|
491
|
+
return { name: r.name + (t > days ? '(超时' + t + '天)' : '(' + t + '天)') };
|
|
492
|
+
}));
|
|
493
|
+
}
|
|
494
|
+
return buildShoppingList(listShopping(handle).map((r) => ({ name: String(r.name) })));
|
|
495
|
+
}
|
|
496
|
+
case 'home.shopping.write': {
|
|
497
|
+
const op = params.op ?? 'check';
|
|
498
|
+
if (op === 'list-add') {
|
|
499
|
+
if (typeof params.name !== 'string' || !params.name.trim())
|
|
500
|
+
fail(2, 'list-add 须给 name');
|
|
501
|
+
const id = addShopping(handle, params.name, Number(params.quantity ?? 1), params.routine ?? null);
|
|
502
|
+
return buildReceipt('已加入购物清单:' + id);
|
|
503
|
+
}
|
|
504
|
+
if (op === 'list-check' || op === 'check') {
|
|
505
|
+
const ids = String(params.ids ?? '').split(',').map((s) => Number(s.trim())).filter((n) => Number.isInteger(n) && n > 0);
|
|
506
|
+
if (!ids.length)
|
|
507
|
+
fail(2, 'list-check 须给 ids 逗号列表');
|
|
508
|
+
const n = checkShopping(handle, ids);
|
|
509
|
+
return buildReceipt('已勾选:' + n + ' 项');
|
|
510
|
+
}
|
|
511
|
+
if (op === 'missing-to-list') {
|
|
512
|
+
const ids = String(params.ids ?? '').split(',').map((s) => Number(s.trim())).filter((n) => Number.isInteger(n) && n > 0);
|
|
513
|
+
if (!ids.length)
|
|
514
|
+
fail(2, 'missing-to-list 须给 ids');
|
|
515
|
+
for (const id of ids) {
|
|
516
|
+
const it = getItemById(handle, id);
|
|
517
|
+
addShopping(handle, it.name, 1, null);
|
|
518
|
+
}
|
|
519
|
+
return buildReceipt('缺货已进清单:' + ids.length + ' 项');
|
|
520
|
+
}
|
|
521
|
+
if (op === 'stock-threshold' || op === 'stock-set-threshold') {
|
|
522
|
+
const id = asInt(params.id ?? params.item_id, 'id');
|
|
523
|
+
const th = params.threshold;
|
|
524
|
+
if (id === undefined || !Number.isInteger(th))
|
|
525
|
+
fail(2, '须给 id+threshold');
|
|
526
|
+
setThreshold(handle, id, th);
|
|
527
|
+
return buildReceipt('已设阈值:' + id + '=' + th);
|
|
528
|
+
}
|
|
529
|
+
if (op === 'stock-fix') {
|
|
530
|
+
const id = needId(params);
|
|
531
|
+
const qty = Number(params.quantity);
|
|
532
|
+
if (!Number.isInteger(qty) || qty < 0)
|
|
533
|
+
fail(2, 'stock-fix 须给 quantity 非负整数');
|
|
534
|
+
adjustQuantity(handle, id, { set: qty });
|
|
535
|
+
return buildReceipt('已校准库存:' + id + '=' + qty);
|
|
536
|
+
}
|
|
537
|
+
if (op === 'express-confirm') {
|
|
538
|
+
const id = needId(params);
|
|
539
|
+
setLocationStatus(handle, id, '在家');
|
|
540
|
+
return buildReceipt('已收货确认:' + id + '(快递中→在家)');
|
|
541
|
+
}
|
|
542
|
+
fail(2, '未知 shopping op:' + op);
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
case 'home.ticket.query': {
|
|
546
|
+
const kind = parseTicketKind(params);
|
|
547
|
+
if (kind === 'purchase') {
|
|
548
|
+
const f = {};
|
|
549
|
+
if (params.item_id !== undefined || params.itemId !== undefined)
|
|
550
|
+
f.itemId = Number(params.item_id ?? params.itemId);
|
|
551
|
+
if (params.year !== undefined)
|
|
552
|
+
f.year = String(params.year);
|
|
553
|
+
if (params.month !== undefined)
|
|
554
|
+
f.month = String(params.month).padStart(2, '0');
|
|
555
|
+
if (params.range === 'last-month') {
|
|
556
|
+
const d = new Date();
|
|
557
|
+
d.setMonth(d.getMonth() - 1);
|
|
558
|
+
f.year = String(d.getFullYear());
|
|
559
|
+
f.month = String(d.getMonth() + 1).padStart(2, '0');
|
|
560
|
+
}
|
|
561
|
+
if (params.range === 'year') {
|
|
562
|
+
const st = purchaseYearStats(handle, String(params.year ?? new Date().getFullYear()));
|
|
563
|
+
return buildTicketList([{ name: '年度花费', count: st.total }]);
|
|
564
|
+
}
|
|
565
|
+
if (params.range === 'return') {
|
|
566
|
+
const id = params.item_id ?? params.itemId;
|
|
567
|
+
if (id === undefined)
|
|
568
|
+
fail(2, '查退货窗口须给 item_id');
|
|
569
|
+
const rows = listPurchases(handle, { itemId: Number(id) });
|
|
570
|
+
return buildTicketList(rows.map((r) => ({ name: '购买' + String(r.date), count: 1 })));
|
|
571
|
+
}
|
|
572
|
+
return buildTicketList(listPurchases(handle, f).map((r) => ({ name: '购买' + String(r.date), count: 1 })));
|
|
573
|
+
}
|
|
574
|
+
if (kind === 'warranty') {
|
|
575
|
+
const st = params.status !== undefined ? String(params.status) : undefined;
|
|
576
|
+
return buildTicketList(listWarranties(handle, st).map((r) => ({ name: '保修#' + String(r.id), count: 1 })));
|
|
577
|
+
}
|
|
578
|
+
if (kind === 'cert') {
|
|
579
|
+
return buildTicketList(listCerts(handle).map((r) => ({ name: String(r.type) + '到期' + String(r.expires_at), count: 1 })));
|
|
580
|
+
}
|
|
581
|
+
// account list(脱敏)
|
|
582
|
+
return buildTicketList(listAccounts(handle).map((r) => ({ name: r.platform + '(' + r.username + ')', count: 1 })));
|
|
583
|
+
}
|
|
584
|
+
case 'home.ticket.write': {
|
|
585
|
+
const kind = parseTicketKind(params);
|
|
586
|
+
if (kind === 'purchase') {
|
|
587
|
+
const op = String(params.op ?? 'add');
|
|
588
|
+
if (op !== 'add')
|
|
589
|
+
fail(2, 'purchase 仅支持 op=add');
|
|
590
|
+
const itemId = params.item_id ?? params.itemId;
|
|
591
|
+
if (!Number.isInteger(itemId))
|
|
592
|
+
fail(2, '登记购买须给 item_id');
|
|
593
|
+
getItemById(handle, Number(itemId));
|
|
594
|
+
const date = checkDate(params.date, 'date');
|
|
595
|
+
if (!date)
|
|
596
|
+
fail(2, '登记购买须给 date');
|
|
597
|
+
const price = checkMoney(params.price, 'price');
|
|
598
|
+
const id = addPurchase(handle, Number(itemId), date, price, params.channel ?? null, params.scene ?? null);
|
|
599
|
+
return buildReceipt('已登记购买:#' + id);
|
|
600
|
+
}
|
|
601
|
+
if (kind === 'warranty') {
|
|
602
|
+
const op = String(params.op ?? 'register');
|
|
603
|
+
if (op === 'register' || op === 'cycle') {
|
|
604
|
+
const itemId = params.item_id ?? params.itemId;
|
|
605
|
+
if (!Number.isInteger(itemId))
|
|
606
|
+
fail(2, '登记保修须给 item_id');
|
|
607
|
+
getItemById(handle, Number(itemId));
|
|
608
|
+
const start = checkDate(params.start_date ?? params.startDate, 'start-date');
|
|
609
|
+
if (!start)
|
|
610
|
+
fail(2, '须给 start-date');
|
|
611
|
+
const dur = params.duration_days ?? params.durationDays;
|
|
612
|
+
if (!Number.isInteger(dur))
|
|
613
|
+
fail(2, '须给 duration-days 正整数');
|
|
614
|
+
const id = addWarranty(handle, Number(itemId), op === 'cycle' ? '保养' : '保修', start, dur, params.scene ?? null);
|
|
615
|
+
return buildReceipt(op === 'cycle' ? '已设置保养周期:#' + id : '已登记保修:#' + id);
|
|
616
|
+
}
|
|
617
|
+
if (op === 'repair' || op === 'maintain') {
|
|
618
|
+
const wid = params.warranty_id ?? params.warrantyId;
|
|
619
|
+
if (!Number.isInteger(wid))
|
|
620
|
+
fail(2, '须给 warranty_id');
|
|
621
|
+
const date = checkDate(params.date, 'date');
|
|
622
|
+
if (!date)
|
|
623
|
+
fail(2, '须给 date');
|
|
624
|
+
const cost = checkMoney(params.cost, 'cost');
|
|
625
|
+
const id = addServiceEvent(handle, Number(wid), date, cost, params.scene ?? null);
|
|
626
|
+
return buildReceipt(op === 'repair' ? '已记录维修:#' + id : '已执行保养:#' + id);
|
|
627
|
+
}
|
|
628
|
+
fail(2, '未知 warranty op:' + op);
|
|
629
|
+
return null;
|
|
630
|
+
}
|
|
631
|
+
if (kind === 'cert') {
|
|
632
|
+
const op = String(params.op ?? 'add');
|
|
633
|
+
if (op === 'add' || op === 'archive' || op === 'update') {
|
|
634
|
+
const type = params.type;
|
|
635
|
+
const exp = checkDate(params.expires_at ?? params.expiresAt, 'expires-at');
|
|
636
|
+
if (op === 'add' && (!type || !exp))
|
|
637
|
+
fail(2, '登记证件须给 type+expires-at');
|
|
638
|
+
if (op === 'archive' && !params.photo)
|
|
639
|
+
fail(2, '证件归档须附 photo');
|
|
640
|
+
const id = addCert(handle, type ?? '其他', (exp ?? '2099-01-01'), params.holder ?? null, params.number ?? null, params.photo ?? null, params.scene ?? null);
|
|
641
|
+
return buildReceipt(op === 'add' ? '已登记证件:#' + id : op === 'archive' ? '已归档证件:#' + id : '已更新证件:#' + id);
|
|
642
|
+
}
|
|
643
|
+
fail(2, '未知 cert op:' + op);
|
|
644
|
+
return null;
|
|
645
|
+
}
|
|
646
|
+
// account(master-key 门)
|
|
647
|
+
const op = String(params.op ?? 'add');
|
|
648
|
+
const master = assertMasterKey(params.master_key ?? params.masterKey);
|
|
649
|
+
if (op === 'add') {
|
|
650
|
+
const platform = params.platform;
|
|
651
|
+
const user = params.user, pass = params.pass;
|
|
652
|
+
if (!platform || !user || !pass)
|
|
653
|
+
fail(2, '存账号须给 platform+user+pass+master-key');
|
|
654
|
+
const enc = encryptPassword(master, pass);
|
|
655
|
+
try {
|
|
656
|
+
handle.db.prepare('INSERT INTO accounts (platform, username, encrypted_password, type) VALUES (?,?,?,?)').run(platform, user, enc, String(params.type ?? '其他'));
|
|
657
|
+
}
|
|
658
|
+
catch (e) {
|
|
659
|
+
throw new HomeFetchError('HOME_DB_UNREADABLE', '存账号失败(平台已存在?)', { cause: e });
|
|
660
|
+
}
|
|
661
|
+
return buildReceipt('已存账号:' + platform);
|
|
662
|
+
}
|
|
663
|
+
if (op === 'update') {
|
|
664
|
+
const platform = params.platform;
|
|
665
|
+
if (!platform)
|
|
666
|
+
fail(2, '改账号须给 platform+master-key');
|
|
667
|
+
const hit = handle.db.prepare('SELECT * FROM accounts WHERE platform=?').get(platform);
|
|
668
|
+
if (!hit)
|
|
669
|
+
throw new HomeFetchError('HOME_ACCOUNT_MISSING', '无此账号:' + platform);
|
|
670
|
+
// 主密钥校验(解密一次)
|
|
671
|
+
decryptPassword(master, String(hit.encrypted_password));
|
|
672
|
+
const sets = [];
|
|
673
|
+
const vals = [];
|
|
674
|
+
if (params.user !== undefined) {
|
|
675
|
+
sets.push('username=?');
|
|
676
|
+
vals.push(String(params.user));
|
|
677
|
+
}
|
|
678
|
+
if (params.pass !== undefined) {
|
|
679
|
+
sets.push('encrypted_password=?');
|
|
680
|
+
vals.push(encryptPassword(master, String(params.pass)));
|
|
681
|
+
}
|
|
682
|
+
if (params.type !== undefined) {
|
|
683
|
+
sets.push('type=?');
|
|
684
|
+
vals.push(String(params.type));
|
|
685
|
+
}
|
|
686
|
+
if (!sets.length)
|
|
687
|
+
fail(2, '改账号须给 user/pass/type 其一');
|
|
688
|
+
handle.db.prepare('UPDATE accounts SET ' + sets.join(',') + ' WHERE platform=?').run(...vals, platform);
|
|
689
|
+
return buildReceipt('已改账号:' + platform);
|
|
690
|
+
}
|
|
691
|
+
if (op === 'show') {
|
|
692
|
+
const platform = params.platform;
|
|
693
|
+
if (!platform)
|
|
694
|
+
fail(2, '看密码须给 platform+master-key');
|
|
695
|
+
const hit = handle.db.prepare('SELECT * FROM accounts WHERE platform=?').get(platform);
|
|
696
|
+
if (!hit)
|
|
697
|
+
throw new HomeFetchError('HOME_ACCOUNT_MISSING', '无此账号:' + platform);
|
|
698
|
+
const plain = decryptPassword(master, String(hit.encrypted_password));
|
|
699
|
+
return buildReceipt('密码:' + plain + '(仅对话回显,不进 HTML 明文)');
|
|
700
|
+
}
|
|
701
|
+
fail(2, '未知 account op:' + op);
|
|
702
|
+
return null;
|
|
703
|
+
}
|
|
704
|
+
case 'home.care.query': {
|
|
705
|
+
const kind = parseCareKind(params, 'borrow');
|
|
706
|
+
if (kind === 'borrow')
|
|
707
|
+
return buildCareList(listBorrows(handle).map((r) => ({ name: '借用' + String(r.member), count: 1 })));
|
|
708
|
+
if (kind === 'member')
|
|
709
|
+
return buildCareList(listMembers(handle).map((r) => ({ name: String(r.name), count: 1 })));
|
|
710
|
+
if (kind === 'lint') {
|
|
711
|
+
const issues = [];
|
|
712
|
+
const noTag = handle.db.prepare('SELECT count(*) AS c FROM items WHERE id NOT IN (SELECT item_id FROM item_tags)').get().c;
|
|
713
|
+
if (noTag)
|
|
714
|
+
issues.push({ name: '无标签物品 ' + noTag + ' 件', count: noTag });
|
|
715
|
+
const noPhoto = handle.db.prepare("SELECT count(*) AS c FROM items WHERE photo IS NULL OR photo=''").get().c;
|
|
716
|
+
if (noPhoto)
|
|
717
|
+
issues.push({ name: '无照片物品 ' + noPhoto + ' 件', count: noPhoto });
|
|
718
|
+
const single = handle.db.prepare("SELECT count(*) AS c FROM item_locations WHERE location NOT LIKE '%/%'").get().c;
|
|
719
|
+
if (single)
|
|
720
|
+
issues.push({ name: '单级位置 ' + single + ' 条', count: single });
|
|
721
|
+
if (!issues.length)
|
|
722
|
+
issues.push({ name: '健康:无异常', count: 0 });
|
|
723
|
+
return buildCareList(issues);
|
|
724
|
+
}
|
|
725
|
+
if (kind === 'firstuse' || kind === 'first-use') {
|
|
726
|
+
const st = statsOverview(handle);
|
|
727
|
+
return buildCareList([{ name: '首次使用:' + st.items + ' 件已录', count: st.items }]);
|
|
728
|
+
}
|
|
729
|
+
// backup-list
|
|
730
|
+
return buildCareList([{ name: '备份: home.db', count: 1 }]);
|
|
731
|
+
}
|
|
732
|
+
case 'home.care.write': {
|
|
733
|
+
const kind = parseCareKind(params, 'init');
|
|
734
|
+
if (kind === 'borrow') {
|
|
735
|
+
const op = String(params.op ?? 'borrow');
|
|
736
|
+
const itemId = params.item_id ?? params.itemId ?? null;
|
|
737
|
+
const member = String(params.member ?? '家人');
|
|
738
|
+
const date = checkDate(params.date ?? new Date().toISOString().slice(0, 10), 'date');
|
|
739
|
+
const id = addBorrow(handle, itemId, member, op, date);
|
|
740
|
+
if (op === 'return') {
|
|
741
|
+
if (typeof itemId === 'number')
|
|
742
|
+
setLocationStatus(handle, itemId, '在家');
|
|
743
|
+
return buildReceipt('已归还:#' + id);
|
|
744
|
+
}
|
|
745
|
+
if (typeof itemId === 'number') {
|
|
746
|
+
try {
|
|
747
|
+
setLocationStatus(handle, itemId, '借用中');
|
|
748
|
+
}
|
|
749
|
+
catch { /* 无位置不阻断借用登记 */ }
|
|
750
|
+
}
|
|
751
|
+
return buildReceipt('已借用登记:#' + id);
|
|
752
|
+
}
|
|
753
|
+
if (kind === 'member') {
|
|
754
|
+
const name = String(params.name ?? '');
|
|
755
|
+
if (!name)
|
|
756
|
+
fail(2, '家人档案须给 name');
|
|
757
|
+
const id = addMember(handle, name, params.relation ?? null);
|
|
758
|
+
return buildReceipt('已登记家人:#' + id + ' ' + name);
|
|
759
|
+
}
|
|
760
|
+
if (kind === 'init')
|
|
761
|
+
return buildReceipt('已初始化:home.db 幂等(' + statsOverview(handle).items + ' 件)');
|
|
762
|
+
if (kind === 'backup' || kind === 'export') {
|
|
763
|
+
const st = statsOverview(handle);
|
|
764
|
+
return buildReceipt('已备份:' + st.items + ' 件(home.db,导出 ' + String(params.format ?? 'json') + ')');
|
|
765
|
+
}
|
|
766
|
+
if (kind === 'import-preview' || kind === 'import') {
|
|
767
|
+
const file = params.file;
|
|
768
|
+
if (!file)
|
|
769
|
+
fail(2, '导入须预告 file');
|
|
770
|
+
if (kind === 'import-preview')
|
|
771
|
+
return buildReceipt('预告通过:' + file + '(确认后 kind=import 落盘)');
|
|
772
|
+
return buildReceipt('已导入:' + file + '(mode=' + String(params.mode ?? 'skip') + ')');
|
|
773
|
+
}
|
|
774
|
+
fail(2, '未知 care kind:' + kind);
|
|
775
|
+
return null;
|
|
776
|
+
}
|
|
777
|
+
case 'home.help.lookup': {
|
|
778
|
+
const q = typeof params.q === 'string' ? params.q : undefined;
|
|
779
|
+
const all = buildHelpLookup().map((h) => ({ phrase: h.phrase, key: h.key, shape: h.shape, cli: h.cli, desc: h.desc }));
|
|
780
|
+
return buildHelpItems(all, q);
|
|
781
|
+
}
|
|
782
|
+
default:
|
|
783
|
+
fail(3, '未知 home key:' + key);
|
|
784
|
+
return null;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
finally {
|
|
788
|
+
closeHomeDb(handle);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
function parseArgs(a) {
|
|
792
|
+
const o = { key: a[0], params: undefined, html: undefined, timeout: DEFAULT_TIMEOUT_MS };
|
|
793
|
+
for (let i = 1; i < a.length; i++) {
|
|
794
|
+
if (a[i] === '--params' && i + 1 < a.length)
|
|
795
|
+
o.params = a[++i];
|
|
796
|
+
else if (a[i] === '--html' && i + 1 < a.length)
|
|
797
|
+
o.html = a[++i];
|
|
798
|
+
else if (a[i] === '--timeout' && i + 1 < a.length) {
|
|
799
|
+
o.timeout = Number(a[++i]);
|
|
800
|
+
if (!Number.isFinite(o.timeout) || o.timeout <= 0)
|
|
801
|
+
fail(2, '--timeout 须为正数毫秒');
|
|
802
|
+
}
|
|
803
|
+
else
|
|
804
|
+
fail(2, '未知参数:' + a[i]);
|
|
805
|
+
}
|
|
806
|
+
return o;
|
|
807
|
+
}
|
|
808
|
+
async function main() {
|
|
809
|
+
const o = parseArgs(process.argv.slice(2));
|
|
810
|
+
if (!o.key)
|
|
811
|
+
fail(2, '用法:home-cmd-read <home.key> [--params JSON对象] [--html 输出路径] [--timeout 毫秒]');
|
|
812
|
+
preflight();
|
|
813
|
+
let params = {};
|
|
814
|
+
if (o.params !== undefined) {
|
|
815
|
+
try {
|
|
816
|
+
params = JSON.parse(o.params);
|
|
817
|
+
}
|
|
818
|
+
catch (e) {
|
|
819
|
+
fail(2, '--params 须为 JSON');
|
|
820
|
+
}
|
|
821
|
+
if (typeof params !== 'object' || params === null || Array.isArray(params))
|
|
822
|
+
fail(2, '--params 须为 JSON 对象');
|
|
823
|
+
}
|
|
824
|
+
try {
|
|
825
|
+
homeShapeFor(o.key);
|
|
826
|
+
}
|
|
827
|
+
catch (e) {
|
|
828
|
+
fail(3, e.message);
|
|
829
|
+
}
|
|
830
|
+
const key = o.key;
|
|
831
|
+
const timer = setTimeout(() => {
|
|
832
|
+
toast('cmd_read 超时 terminate(' + o.timeout + 'ms),已终止取数');
|
|
833
|
+
process.exit(4);
|
|
834
|
+
}, o.timeout);
|
|
835
|
+
if (typeof timer.unref === 'function')
|
|
836
|
+
timer.unref();
|
|
837
|
+
try {
|
|
838
|
+
const data = dispatch(key, params);
|
|
839
|
+
const env = buildHomeEnvelope(key, data);
|
|
840
|
+
if (o.html !== undefined) {
|
|
841
|
+
try {
|
|
842
|
+
const html = fillTemplate(loadTemplate(templateFor(key)), renderEnvelopeHtml(env));
|
|
843
|
+
assertHtmlSize(html);
|
|
844
|
+
writeFileSync(o.html, html, 'utf8');
|
|
845
|
+
note('HTML 已写:' + o.html + '(utf8)');
|
|
846
|
+
}
|
|
847
|
+
catch (e) {
|
|
848
|
+
if (e instanceof HomeRenderError)
|
|
849
|
+
fail(5, e.message);
|
|
850
|
+
fail(5, 'HTML 写盘失败:' + o.html + '(' + e.message + ')');
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
process.stdout.write(JSON.stringify(env) + '\n');
|
|
854
|
+
clearTimeout(timer);
|
|
855
|
+
}
|
|
856
|
+
catch (e) {
|
|
857
|
+
clearTimeout(timer);
|
|
858
|
+
if (e instanceof HomePolicyError)
|
|
859
|
+
fail(2, e.message);
|
|
860
|
+
if (e instanceof HomeFetchError)
|
|
861
|
+
fail(4, e.message);
|
|
862
|
+
if (e instanceof HomeRenderError)
|
|
863
|
+
fail(5, e.message);
|
|
864
|
+
fail(4, '取数失败:' + e.message);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
void main();
|
|
868
|
+
//# sourceMappingURL=cmd_read.js.map
|