fe17886ac4
- 车辆 / 洗车 / 加油 / 充电 / 保养 / 保险 完整 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
427 lines
17 KiB
JavaScript
427 lines
17 KiB
JavaScript
// 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 = '1–1440';
|
||
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;
|