65b0bb04f8
把 CarLog v2.8 全套源码 + 配置导入到 i 仓库作为 baseline: - server/src/ (13 个路由 + middleware + services + config) - server/migrations/ (0001~0018 共 18 个迁移 + mysql) - server/test/ (12 文件 101 测试) - client/src/ (20 个 view + components + stores + api + composables) - client/public/ + client/scripts/ - 全部配置文件 (.editorconfig, .eslintrc.json, .prettierrc.json, vitest.config.js, lighthouserc.json, .pa11yci.json, package.json, carlog-init.sql) - .husky/pre-commit (git hooks) - docs/install/ (宝塔部署文档) 不含: - node_modules/ (本地 npm install) - .env (敏感, 走 .env.example) - *.zip / *.log / *.sqlite / .DS_Store 新增文档 docs/DEV-PLAN.md: - Phase 1: 平台基座 (019 migration + 3 个 platform 路由 + 3 个 view) - Phase 2: CarLog 子系统化 (后端 routes/ → subsystems/carlog/ + 前端 views/ → views/subsystems/carlog/ + 元数据驱动菜单) - Phase 3: 验证 (测试 + E2E + DB 完整性) - 交付清单 + commit 模板 + 给 Mavis review 的材料 后续 Trae 实施, 提交后我 code review + 跑测试。
87 lines
3.7 KiB
JavaScript
87 lines
3.7 KiB
JavaScript
// server/src/services/grocy.js — Grocy 库存扣减同步
|
||
import { httpGet, httpPost } from '../http.js';
|
||
import { db } from '../db.js';
|
||
|
||
/**
|
||
* 同步 chemical_usage 表到 Grocy 库存扣减
|
||
* @param {object} cfg
|
||
* @param {object} opts
|
||
* @param {string} [opts.since] YYYY-MM-DD,只同步此日期之后的
|
||
* @param {boolean} [opts.dryRun]
|
||
* @returns {Promise<{ok:number, fail:number, items:Array}>}
|
||
*/
|
||
export async function syncUsageToGrocy(cfg, opts = {}) {
|
||
if (!cfg.grocy.url) throw new Error('未配置 GROCY_URL');
|
||
if (!cfg.grocy.api_token) throw new Error('未配置 GROCY_API_TOKEN');
|
||
const since = opts.since || isoDaysAgo(7);
|
||
const rows = await db().all(
|
||
`SELECT cu.id, cu.usage_date, cu.chemical_id, cu.amount, c.name
|
||
FROM chemical_usage cu
|
||
LEFT JOIN chemicals c ON c.grocy_product_id = cu.chemical_id
|
||
WHERE cu.sync_status = 'pending' AND cu.usage_date >= ?
|
||
ORDER BY cu.usage_date, cu.id`, [since]);
|
||
|
||
// 按 product 聚合
|
||
const agg = {};
|
||
for (const r of rows) {
|
||
const k = r.chemical_id;
|
||
if (!agg[k]) agg[k] = { product_id: k, name: r.name, total: 0, ids: [] };
|
||
agg[k].total += r.amount;
|
||
agg[k].ids.push(r.id);
|
||
}
|
||
|
||
const results = [];
|
||
for (const a of Object.values(agg)) {
|
||
const url = `${cfg.grocy.url}/api/stock/products/${encodeURIComponent(a.product_id)}/consume`;
|
||
const amount = Math.round(a.total * 100) / 100; // 保留 2 位小数
|
||
if (opts.dryRun) {
|
||
results.push({ product_id: a.product_id, name: a.name, amount, status: 'dry-run' });
|
||
continue;
|
||
}
|
||
try {
|
||
await httpPost(url, { amount }, {
|
||
headers: { 'GROCY-API-TOKEN': cfg.grocy.api_token, 'Content-Type': 'application/json' },
|
||
timeout: 10000,
|
||
});
|
||
// 标记 synced
|
||
const placeholders = a.ids.map(() => '?').join(',');
|
||
(await db().run(`UPDATE chemical_usage SET sync_status = 'synced', sync_at = NOW(), updated_at = NOW() WHERE id IN (${placeholders})`, [...a.ids]));
|
||
results.push({ product_id: a.product_id, name: a.name, amount, status: 'ok' });
|
||
} catch (e) {
|
||
(await db().run(`UPDATE chemical_usage SET sync_status = 'failed', updated_at = NOW() WHERE id IN (${a.ids.map(() => '?').join(',')})`, [...a.ids]));
|
||
results.push({ product_id: a.product_id, name: a.name, amount, status: 'fail', error: e.message });
|
||
}
|
||
}
|
||
return { ok: results.filter(r => r.status === 'ok').length, fail: results.filter(r => r.status === 'fail').length, items: results };
|
||
}
|
||
|
||
export async function cli(argv, cfg) {
|
||
if (argv.includes('--help') || argv.includes('-h')) {
|
||
console.log(`Usage: grocy-sync [--since=YYYY-MM-DD] [--dry-run]
|
||
|
||
把 chemical_usage 表中 sync_status=pending 的记录聚合到 Grocy 库存扣减。`);
|
||
return;
|
||
}
|
||
const args = parseArgs(argv);
|
||
cfg = cfg || (await import('../config.js')).loadConfig();
|
||
if (!cfg.grocy.url || !cfg.grocy.api_token) {
|
||
console.log('✗ 未配置 GROCY_URL / GROCY_API_TOKEN(设置 → Grocy)');
|
||
process.exit(78);
|
||
}
|
||
const r = await syncUsageToGrocy(cfg, { since: args.since, dryRun: args['dry-run'] });
|
||
console.log(`✓ Grocy sync: ok=${r.ok} fail=${r.fail}`);
|
||
for (const it of r.items) {
|
||
console.log(` [${it.status}] ${it.product_id} ${it.name} ${it.amount}`);
|
||
}
|
||
}
|
||
|
||
function parseArgs(argv) {
|
||
const a = {};
|
||
for (const x of argv) {
|
||
const m = x.match(/^--([^=]+)(?:=(.*))?$/);
|
||
if (m) a[m[1]] = m[2] ?? true;
|
||
}
|
||
return a;
|
||
}
|
||
function isoDaysAgo(d) { return new Date(Date.now() - d * 86400 * 1000).toISOString().slice(0, 10); }
|