Files
CarLog/server/src/routes/washes.js
T
wsh5485 fe17886ac4 feat: 洗车管理系统 v2.8 — 个人 detailer 单用户全栈应用
- 车辆 / 洗车 / 加油 / 充电 / 保养 / 保险 完整 CRUD + 软删
- AI 截图识别(5 类型 OCR schema):OpenAI 兼容 + MiniMax M3
- 化学品 / Grocy 实例对接 + 库存镜像同步
- 仪表盘:30 天频次 + 健康度 + 同比环比 + 油价趋势 + 年均养护
- 月度报表:Excel 6 sheet + PDF
- PWA:manifest / SW / 离线缓存 / iOS 引导
- 安全:bcrypt + CSRF + 登录锁定(IP/用户/全局三级)+ 401 自动跳登录 + 表单草稿
- 高 ROI 8 功能:里程/提醒/成本/搜索/标签/通知/同比/成就
- 3 个新 migration(0016/0017/0018)+ 18 个迁移全幂等
- 101/101 测试通过(含 ipRateLimit / CSRF / retry / stats / tags / notifications)
- 部署:宝塔面板文档 + PM2 + Nginx
2026-06-20 21:11:54 +08:00

427 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// server/src/routes/washes.js — 洗车记录 CRUD(含 Grocy 扣减 + 对比照)
import { Router } from 'express';
import multer from 'multer';
import path from 'node:path';
import fs from 'node:fs';
import url from 'node:url';
import { db } from '../db.js';
import { consumeGrocyStock } from '../services/grocyWrite.js';
import { grocyGet } from '../services/grocyClient.js';
import { loadConfig } from '../config.js';
import { logOperation } from '../services/operationLog.js';
import { verifyChallenge } from '../services/challenge.js';
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
const PHOTOS_DIR = path.join(__dirname, '../../../uploads/washes');
fs.mkdirSync(PHOTOS_DIR, { recursive: true });
const photoStorage = multer.diskStorage({
destination: (req, file, cb) => cb(null, PHOTOS_DIR),
filename: (req, file, cb) => {
const ts = Date.now(),
rand = Math.random().toString(36).slice(2, 8);
const ext = path.extname(file.originalname).toLowerCase() || '.jpg';
cb(null, `wash-${ts}-${rand}${ext}`);
},
});
const photoUpload = multer({
storage: photoStorage,
limits: { fileSize: 15 * 1024 * 1024 }, // 15 MB
fileFilter: (req, file, cb) => {
const ok = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/heic', 'image/heif'].includes(
file.mimetype
);
cb(null, ok);
},
});
const router = Router();
const TYPES = ['quick', 'full', 'detail', 'other'];
const LABEL = { quick: '快速', full: '标准', detail: '精洗', other: '其他' };
function ok(res, data) {
res.json(data);
}
function fail(res, status, code, message, extra) {
res.status(status).json({ ok: false, error: { code, message, ...(extra || {}) } });
}
function today() {
return new Date().toISOString().slice(0, 10);
}
function isoDaysAgo(d) {
return new Date(Date.now() - d * 86400 * 1000).toISOString().slice(0, 10);
}
// GET /api/washes?from=&to=&type=&vehicle_id=&page=&limit=
/**
* @openapi
* /api/washes:
* get:
* tags: [washes]
* summary: 列出洗车记录(分页 + 过滤)
* parameters:
* - in: query
* name: from
* schema: { type: string, format: date }
* description: 起日期 YYYY-MM-DD
* - in: query
* name: to
* schema: { type: string, format: date }
* - in: query
* name: type
* schema: { type: string, enum: [quick, full, detail, other] }
* - in: query
* name: vehicle_id
* schema: { type: integer }
* - in: query
* name: page
* schema: { type: integer, default: 1 }
* - in: query
* name: limit
* schema: { type: integer, default: 50 }
* responses:
* 200: { description: OK }
*/
router.get('/washes', async (req, res) => {
const from = req.query.from || isoDaysAgo(90);
const to = req.query.to || today();
const type = req.query.type || null;
const vehicleId = req.query.vehicle_id || null;
const page = Math.max(1, parseInt(req.query.page || '1'));
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit || '20')));
const offset = (page - 1) * limit;
let sql = `SELECT w.id, w.wash_date, w.wash_type, w.cost, w.location, w.notes, w.vehicle_id,
w.duration_min, w.created_at, w.updated_at,
ws.weather_desc, ws.weather_code, ws.temp_c, ws.humidity, ws.city,
v.name AS vehicle_name, v.plate AS vehicle_plate, v.type AS vehicle_type
FROM wash_records w
LEFT JOIN weather_snapshots ws ON ws.id = w.weather_snapshot_id
LEFT JOIN vehicles v ON v.id = w.vehicle_id
WHERE w.wash_date BETWEEN ? AND ? AND w.is_deleted = 0`;
const params = [from, to];
if (type) {
sql += ' AND w.wash_type = ?';
params.push(type);
}
if (vehicleId) {
sql += ' AND w.vehicle_id = ?';
params.push(vehicleId);
}
sql += ' ORDER BY w.wash_date DESC, w.id DESC LIMIT ? OFFSET ?';
params.push(limit, offset);
const rows = await db().all(sql, params);
let countSql = 'SELECT COUNT(*) AS c FROM wash_records WHERE wash_date BETWEEN ? AND ? AND is_deleted = 0';
const countParams = [from, to];
if (type) {
countSql += ' AND wash_type = ?';
countParams.push(type);
}
if (vehicleId) {
countSql += ' AND vehicle_id = ?';
countParams.push(vehicleId);
}
const total = (await db().get(countSql, countParams))?.c || 0;
ok(res, { rows, total, page, limit, total_pages: Math.ceil(total / limit), from, to });
});
// GET /api/washes/types
router.get('/washes/types', async (req, res) => {
ok(
res,
TYPES.map((v) => ({ value: v, label: LABEL[v] }))
);
});
// GET /api/washes/:id
router.get('/washes/:id', async (req, res) => {
const row = await db().get(
`SELECT w.*, ws.weather_desc, ws.temp_c, ws.humidity, ws.weather_code, ws.city AS weather_city,
v.name AS vehicle_name, v.plate AS vehicle_plate, v.type AS vehicle_type
FROM wash_records w
LEFT JOIN weather_snapshots ws ON ws.id = w.weather_snapshot_id
LEFT JOIN vehicles v ON v.id = w.vehicle_id
WHERE w.id = ?`,
[req.params.id]
);
if (!row) return fail(res, 404, 'NOT_FOUND', '记录不存在');
const chemicals = await db().all(
`SELECT cu.id, cu.chemical_id, cu.amount, cu.usage_date, cu.notes, cu.sync_status, cu.sync_at,
cu.created_at, c.name AS chemical_name, c.unit, c.category
FROM chemical_usage cu
LEFT JOIN chemicals c ON c.grocy_product_id = cu.chemical_id
WHERE cu.wash_record_id = ?
ORDER BY cu.id DESC`,
[req.params.id]
);
ok(res, { ...row, chemicals });
});
// POST /api/washes
router.post('/washes', async (req, res) => {
const b = req.body || {};
const errors = {};
if (!b.wash_date || !/^\d{4}-\d{2}-\d{2}$/.test(b.wash_date)) errors.wash_date = '必填(YYYY-MM-DD';
else if (b.wash_date > today()) errors.wash_date = '不能晚于今天';
if (!TYPES.includes(b.wash_type)) errors.wash_type = `必填(${TYPES.join('/')}`;
if (b.cost == null || b.cost === '' || isNaN(b.cost) || Number(b.cost) < 0) errors.cost = '必填且 ≥ 0';
if (
b.duration_min != null &&
b.duration_min !== '' &&
(isNaN(b.duration_min) || Number(b.duration_min) < 1 || Number(b.duration_min) > 1440)
)
errors.duration_min = '11440';
if (b.vehicle_id) {
const v = await db().get('SELECT is_active FROM vehicles WHERE id = ? AND is_deleted = 0', [b.vehicle_id]);
if (!v || !v.is_active) errors.vehicle_id = '车辆不存在或已停用';
}
if (Object.keys(errors).length) return fail(res, 422, 'VALIDATION', '校验失败', { errors });
const info = await db().run(
`INSERT INTO wash_records (wash_date, wash_type, vehicle_id, location, cost, duration_min, notes)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[
b.wash_date,
b.wash_type,
b.vehicle_id || null,
b.location || null,
Number(b.cost),
b.duration_min ? Number(b.duration_min) : null,
b.notes || null,
]
);
const washId = Number(info.lastInsertRowid);
// 保存 chemicals + 异步同步到 Grocy
if (Array.isArray(b.chemicals) && b.chemicals.length) {
const usageIds = [];
for (const c of b.chemicals) {
if (!c.chemical_id || !c.amount) continue;
const chem = await db().get(
'SELECT qu_factor, qu_id, consume_unit_id, unit FROM chemicals WHERE grocy_product_id = ?',
[c.chemical_id]
);
const quFactor = chem ? Number(chem.qu_factor || 1) : 1;
const inputAmount = Number(c.amount);
const stockAmount = Math.round(inputAmount * quFactor * 1000) / 1000;
const r = await db().run(
`INSERT INTO chemical_usage (usage_date, chemical_id, amount, unit, stock_amount, consume_unit_id, wash_record_id, notes, sync_status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', NOW(), NOW())`,
[
b.wash_date,
c.chemical_id,
inputAmount,
c.unit || chem?.unit || null,
stockAmount,
chem?.consume_unit_id || null,
washId,
c.notes || null,
]
);
usageIds.push({ id: Number(r.lastInsertRowid), chemical_id: c.chemical_id, stock_amount: stockAmount });
}
if (usageIds.length) syncChemicalsToGrocyInBackground(usageIds, b.wash_date, washId);
}
ok(res, { id: washId });
});
/**
* 后台把 chemical_usage 同步到 Grocy 扣减库存
*/
function syncChemicalsToGrocyInBackground(usageIds, washDate, washId) {
setImmediate(async () => {
const cfg = await loadConfig();
if (!cfg.grocy.url) {
if (usageIds[0])
await db().run("UPDATE chemical_usage SET sync_status = 'failed', updated_at = NOW() WHERE id = ?", [
usageIds[0].id,
]);
return;
}
for (const u of usageIds) {
try {
const chem = await db().get('SELECT source, name FROM chemicals WHERE grocy_product_id = ?', [
u.chemical_id,
]);
if (!chem || chem.source !== 'grocy') {
await db().run(
"UPDATE chemical_usage SET sync_status = 'skipped', updated_at = NOW() WHERE id = ?",
[u.id]
);
continue;
}
await consumeGrocyStock(cfg, u.chemical_id, {
amount: u.stock_amount,
transaction_type: 'consume',
note: `洗车记录 #${washId} (${washDate})`,
});
await db().run(
"UPDATE chemical_usage SET sync_status = 'synced', sync_at = NOW(), updated_at = NOW() WHERE id = ?",
[u.id]
);
} catch (e) {
await db().run("UPDATE chemical_usage SET sync_status = 'failed', updated_at = NOW() WHERE id = ?", [
u.id,
]);
console.error(`[grocy consume] failed for ${u.chemical_id}: ${e.message}`);
}
}
try {
const { pullProducts } = await import('../services/grocyProducts.js');
await pullProducts(cfg);
} catch {}
});
}
// 取一批 id 存在的 wash 记录(用于删除前快照)
async function fetchForDelete(ids) {
if (!ids.length) return [];
const placeholders = ids.map(() => '?').join(',');
return await db().all(
`SELECT w.id, w.wash_date, w.wash_type, w.cost, w.location, w.notes, w.vehicle_id,
w.duration_min, w.created_at, v.name AS vehicle_name, v.plate AS vehicle_plate
FROM wash_records w
LEFT JOIN vehicles v ON v.id = w.vehicle_id
WHERE w.id IN (${placeholders}) AND w.is_deleted = 0`,
ids
);
}
// DELETE /api/washes/:id —— 软删(is_deleted=1
router.delete('/washes/:id', async (req, res) => {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) return fail(res, 400, 'BAD_ID', 'id 非法');
const snapshots = await fetchForDelete([id]);
if (!snapshots.length) return fail(res, 404, 'NOT_FOUND', '记录不存在');
await db().run('UPDATE chemical_usage SET is_deleted = 1 WHERE wash_record_id = ?', [id]);
await db().run('UPDATE wash_records SET is_deleted = 1 WHERE id = ?', [id]);
const s = snapshots[0];
logOperation({
req,
action: 'delete',
targetType: 'wash_record',
targetIds: [id],
summary:
`删除洗车 ${s.wash_date} ${LABEL[s.wash_type] || s.wash_type} ¥${Number(s.cost).toFixed(2)}` +
(s.vehicle_name ? ` / ${s.vehicle_name}` : ''),
detail: { snapshot: s },
});
ok(res, { deleted: 1 });
});
// POST /api/washes/batch-delete —— 批量软删(is_deleted=1
router.post('/washes/batch-delete', async (req, res) => {
const b = req.body || {};
const ids = Array.isArray(b.ids) ? b.ids.map(Number).filter(Number.isInteger) : [];
if (!ids.length) return fail(res, 400, 'NO_IDS', 'ids 必填且非空');
if (ids.length > 500) return fail(res, 400, 'TOO_MANY', '单次最多 500 条');
// 二次确认:计算题校验(防误删/防脚本)
const ok = verifyChallenge(b.challenge || {});
if (!ok) return fail(res, 422, 'CONFIRM_FAIL', '二次确认校验失败,请重做计算题');
const snapshots = await fetchForDelete(ids);
if (!snapshots.length) return fail(res, 404, 'NOT_FOUND', '记录不存在');
const placeholders = ids.map(() => '?').join(',');
await db().run(`UPDATE chemical_usage SET is_deleted = 1 WHERE wash_record_id IN (${placeholders})`, ids);
await db().run(`UPDATE wash_records SET is_deleted = 1 WHERE id IN (${placeholders})`, ids);
logOperation({
req,
action: 'batch_delete',
targetType: 'wash_record',
targetIds: snapshots.map((s) => s.id),
summary: `批量删除 ${snapshots.length} 条洗车记录(合计 ¥${snapshots.reduce((s, x) => s + Number(x.cost || 0), 0).toFixed(2)}`,
detail: { snapshots, challenge: c },
});
ok(res, { deleted: snapshots.length });
});
// ====== 洗车对比照 ======
const PHOTO_TYPES = new Set(['before', 'after', 'detail', 'scene']);
// POST /api/washes/:id/photos — 上传一张照片
router.post('/washes/:id/photos', photoUpload.single('file'), async (req, res) => {
try {
if (!req.file) return fail(res, 422, 'BAD_IMAGE', '请上传图片(jpg/png/webp/heic),最大 15MB');
const wid = Number(req.params.id);
const wash = await db().get('SELECT id FROM wash_records WHERE id = ? AND is_deleted = 0', [wid]);
if (!wash) return fail(res, 404, 'NOT_FOUND', '洗车记录不存在');
const photoType = PHOTO_TYPES.has(req.body.photo_type) ? req.body.photo_type : 'detail';
const relPath = path.relative(path.join(__dirname, '../../..'), req.file.path).replace(/\\/g, '/');
const r = await db().run(
`INSERT INTO wash_photos (wash_id, photo_type, file_path, file_name, mime_type, file_size, caption, sort_order, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW())`,
[
wid,
photoType,
relPath,
req.file.originalname,
req.file.mimetype,
req.file.size,
req.body.caption || null,
Number(req.body.sort_order || 0),
]
);
ok(res, {
id: Number(r.lastInsertRowid),
url: `/api/${relPath}`,
photo_type: photoType,
file_name: req.file.originalname,
});
} catch (e) {
fail(res, 500, 'UPLOAD_FAIL', e.message);
}
});
// GET /api/washes/:id/photos — 列出某条洗车的所有照片
router.get('/washes/:id/photos', async (req, res) => {
const rows = await db().all(
`SELECT id, photo_type, file_path, file_name, mime_type, file_size, caption, sort_order, created_at
FROM wash_photos WHERE wash_id = ? AND is_deleted = 0 ORDER BY photo_type, sort_order, id`,
[req.params.id]
);
// 加 url 字段
for (const r of rows) r.url = `/api/${r.file_path}`;
ok(res, rows);
});
// DELETE /api/washes/:id/photos/:photoId — 软删一张
router.delete('/washes/:id/photos/:photoId', async (req, res) => {
const r = await db().run(`UPDATE wash_photos SET is_deleted = 1 WHERE id = ? AND wash_id = ?`, [
req.params.photoId,
req.params.id,
]);
if (!r.changes) return fail(res, 404, 'NOT_FOUND', '照片不存在');
ok(res, { id: Number(req.params.photoId), deleted: true });
});
// GET /api/washes/:id/photos/compare?type1=before&type2=after — 拿一张照片做对比(前后对照)
router.get('/washes/:id/photos/compare', async (req, res) => {
const type1 = req.query.type1 || 'before';
const type2 = req.query.type2 || 'after';
const [b, a] = await Promise.all([
db().get(
`SELECT * FROM wash_photos WHERE wash_id = ? AND photo_type = ? AND is_deleted = 0 ORDER BY sort_order, id LIMIT 1`,
[req.params.id, type1]
),
db().get(
`SELECT * FROM wash_photos WHERE wash_id = ? AND photo_type = ? AND is_deleted = 0 ORDER BY sort_order, id LIMIT 1`,
[req.params.id, type2]
),
]);
ok(res, {
before: b ? { ...b, url: `/api/${b.file_path}` } : null,
after: a ? { ...a, url: `/api/${a.file_path}` } : null,
});
});
export default router;