const express = require('express'); const crypto = require('crypto'); const router = express.Router(); const DataService = require('../services/data'); const LogicService = require('../services/logic'); const AIAssistantService = require('../services/ai-assistant'); const io = require('../io'); // Helper to get IP const getIp = (req) => (req.headers['x-forwarded-for'] || '').toString().split(',')[0].trim() || req.ip || req.connection?.remoteAddress || ''; const normalizeTicketId = (v) => { const s0 = String(v || '').replace(/\s+/g, ''); if (!s0) return ''; const m = s0.match(/^([A-Za-z]{2})-?([0-9]+)$/); if (m) { const prefix = m[1].toUpperCase(); let num = m[2]; if (num.length < 8) num = num.padStart(8, '0'); else if (num.length > 8) num = num.slice(-8); return `${prefix}-${num}`; } return s0.toLowerCase(); }; const normalizeIcCardId = (v) => { const s0 = String(v || '').replace(/\s+/g, '').toUpperCase(); if (!s0) return ''; const m = s0.match(/^IC-?([0-9]+)$/); if (m) return `IC-${m[1].padStart(6, '0').slice(-6)}`; return s0; }; const buildIcCardId = () => { const idx = DataService.getIcCardIndex() || {}; let id = ''; do { id = `IC-${String(crypto.randomInt(0, 1000000)).padStart(6, '0')}`; } while (idx[id]); return id; }; const mapIcCardStatus = (status) => { const s = String(status || '').trim().toLowerCase(); if (s === 'pending_pickup') return '待领卡'; if (s === 'active') return '正常'; if (s === 'disabled') return '停用'; if (s === 'lost') return '挂失'; if (s === 'refunded') return '已退卡'; return status || '未知'; }; const mapIcCardType = (type) => { const t = String(type || '').trim().toLowerCase(); if (t === 'stored_value') return '储值卡'; if (t === 'monthly') return '月票卡'; if (t === 'tourist') return '纪念卡'; return type || '未分类'; }; const IC_CARD_HOLDER_NAME_RE = /^[A-Za-z][A-Za-z .,'()&@/_\-+]*$/; const displayIcCardId = (card) => { const status = String(card?.status || '').trim().toLowerCase(); const source = String(card?.source || '').trim().toLowerCase(); const rawId = String(card?.card_id || '').trim(); if (status === 'pending_pickup' && source === 'online') return '待出卡'; return rawId || '---'; }; const presentIcCard = (card) => card ? ({ ...card, display_card_id: displayIcCardId(card) }) : card; const appendReqLog = (req, { category, type, detail, source, level } = {}) => { const entry = { ts: new Date().toISOString(), ip: getIp(req), ua: String(req.headers['user-agent'] || ''), method: req.method, path: req.originalUrl || req.path || '', category: String(category || '').trim() || 'admin', source: source == null ? undefined : String(source || '').trim(), level: level == null ? undefined : String(level || '').trim(), type: String(type || '').trim() || 'event', detail: (detail === undefined) ? null : detail }; DataService.appendLog(entry); }; const normalizeOrderCode = (v) => String(v || '').trim().toUpperCase(); const toMoney = (v) => { const n = Number(v); if (!Number.isFinite(n)) return 0; return Math.round(n * 100) / 100; }; const toFiniteNumberOrUndef = (v) => { if (v == null) return undefined; const n = Number(v); return Number.isFinite(n) ? n : undefined; }; const isTruthy = (v) => v === true || v === 1 || v === '1' || String(v || '').toLowerCase() === 'true'; const buildStationResolver = () => { const stations = DataService.getStations?.() || []; const codeByKey = new Map(); const nameToCode = new Map(); const normKey = (v) => String(v || '').trim().toUpperCase().replace(/\s+/g, ''); const looksLikeStationCode = (v) => /^[A-Z0-9]+(?:-[A-Z0-9]+)+$/i.test(String(v || '').trim()); for (const s of stations) { if (!s) continue; const code = String(s.code || '').trim(); if (!code) continue; codeByKey.set(normKey(code), code); const cn = String(s.name || s.cn_name || s.station_name || '').trim(); if (cn) nameToCode.set(normKey(cn), code); const en = String(s.en_name || s.en || s.enName || '').trim(); if (en) nameToCode.set(normKey(en), code); } return (v) => { const raw = String(v || '').trim(); if (!raw) return ''; const key = normKey(raw); if (codeByKey.has(key)) return codeByKey.get(key); if (looksLikeStationCode(raw)) return raw; return nameToCode.get(key) || raw; }; }; const findIcCardByOrderCode = (code) => { const normalized = normalizeOrderCode(code); if (!normalized) return null; return (DataService.getIcCards() || []).find((item) => normalizeOrderCode(item?.order_code) === normalized) || null; }; const resolveCurrentStationCode = (body, resolveStation) => { const codes = Array.isArray(body?.station_codes) ? body.station_codes.map((item) => resolveStation(item)).filter(Boolean) : []; const direct = resolveStation(body?.station_code); const fallback = resolveStation(DataService.getConfig?.().current_station || ''); return codes[0] || direct || fallback || ''; }; // Config router.get('/config', (req, res) => { const cfg = DataService.getConfig(); res.json({ api_base: cfg.api_base, current_station: cfg.current_station, stations: DataService.getStations(), lines: DataService.getLines(), fares: DataService.getFares(), transfers: cfg.transfers || [], promotion: cfg.promotion || { name: '', discount: 1 }, lua_versions: cfg.lua_versions || {} }); }); router.post('/ai-assistant', async (req, res) => { try { const message = String(req.body?.message || '').trim(); if (!message) return res.status(400).json({ ok: false, error: 'message required' }); const result = await AIAssistantService.askAssistant({ message, history: req.body?.history, page: req.body?.page, context: req.body?.context, config: DataService.getConfig(), stations: DataService.getStations(), lines: DataService.getLines(), fares: DataService.getFares() }); appendReqLog(req, { category: 'public', type: 'ai_assistant_ask', detail: { page: String(req.body?.page || '').trim(), question: message.slice(0, 280), model: result.model } }); res.json({ ok: true, reply: result.reply, model: result.model }); } catch (e) { const code = e?.code || ''; const status = code === 'INVALID_MESSAGE' ? 400 : (code === 'AI_NOT_CONFIGURED' ? 503 : 500); appendReqLog(req, { category: 'system', type: 'ai_assistant_failed', level: status >= 500 ? 'error' : 'warn', detail: { page: String(req.body?.page || '').trim(), error: e?.message || String(e) } }); res.status(status).json({ ok: false, error: code === 'AI_NOT_CONFIGURED' ? 'AI 助手尚未配置 DeepSeek API Key' : (e?.message || 'ai assistant request failed') }); } }); router.put('/config', async (req, res) => { try { const incoming = req.body || {}; const cfg = DataService.getConfig(); if (incoming.api_base && typeof incoming.api_base === 'string') cfg.api_base = incoming.api_base; if (Array.isArray(incoming.transfers)) cfg.transfers = incoming.transfers; if (incoming.current_station && typeof incoming.current_station === 'object') cfg.current_station = incoming.current_station; if (incoming.promotion) { const p = incoming.promotion || {}; const d = Number(p.discount); if (!(d >= 0 && d <= 1)) return res.status(400).json({ ok: false, error: 'promotion.discount must be between 0 and 1' }); cfg.promotion = { name: String(p.name || ''), discount: d }; } if (incoming.lua_versions && typeof incoming.lua_versions === 'object') { cfg.lua_versions = { ...(cfg.lua_versions || {}), ...(incoming.lua_versions || {}) }; } await DataService.saveConfig(cfg); appendReqLog(req, { category: 'admin', type: 'update_config_generic', detail: incoming }); io.emit('config:updated', cfg); res.json({ ok: true, config: cfg }); } catch (e) { appendReqLog(req, { category: 'system', type: 'update_config_generic_failed', level: 'error', detail: { error: e?.message || String(e) } }); res.status(500).json({ ok: false, error: 'failed to save config' }); } }); // Stations router.get('/stations', (req, res) => res.json(DataService.getStations())); router.post('/stations', async (req, res) => { try { const all = DataService.getStations(); all.push(req.body); await DataService.saveStations(all); appendReqLog(req, { category: 'admin', type: 'update_station', detail: req.body }); io.emit('stations:updated', all); res.json({ ok: true }); } catch (e) { appendReqLog(req, { category: 'system', type: 'update_station_failed', level: 'error', detail: { error: e?.message || String(e), payload: req.body } }); res.status(500).json({ ok: false, error: 'failed to save stations' }); } }); router.put('/stations/:code', async (req, res) => { try { const all = DataService.getStations(); const idx = all.findIndex(s => s.code === req.params.code); if (idx < 0) return res.status(404).json({ ok: false, error: 'station not found' }); const incoming = req.body || {}; const current = all[idx] || {}; const oldCode = String(current.code || '').trim(); const requestedCode = (incoming.code == null) ? oldCode : String(incoming.code || '').trim(); const renaming = requestedCode && oldCode && requestedCode !== oldCode; if (!requestedCode) return res.status(400).json({ ok: false, error: 'station code required' }); if (renaming && all.some(s => String(s.code || '').trim() === requestedCode)) { return res.status(409).json({ ok: false, error: 'station code already exists' }); } const updated = { ...current, ...incoming, code: requestedCode }; all[idx] = updated; const patchCode = (v) => (String(v || '').trim() === oldCode ? requestedCode : v); const patchList = (arr) => (Array.isArray(arr) ? arr.map(x => patchCode(x)).filter(Boolean) : arr); for (let i = 0; i < all.length; i++) { if (i === idx) continue; const s = all[i]; if (s && s.transfer_to) { all[i] = { ...s, transfer_to: patchList(s.transfer_to) }; } } const cfg = DataService.getConfig(); let cfgChanged = false; if (cfg && cfg.current_station && cfg.current_station.code === oldCode) { cfg.current_station.code = requestedCode; cfgChanged = true; } if (cfg && Array.isArray(cfg.transfers)) { const before = JSON.stringify(cfg.transfers); cfg.transfers = cfg.transfers.map(p => Array.isArray(p) ? [patchCode(p[0]), patchCode(p[1])] : p); if (JSON.stringify(cfg.transfers) !== before) cfgChanged = true; } const lines = DataService.getLines(); let linesChanged = false; for (let i = 0; i < lines.length; i++) { const l = lines[i]; if (!l || !Array.isArray(l.stations)) continue; const before = JSON.stringify(l.stations); const after = patchList(l.stations); if (JSON.stringify(after) !== before) { lines[i] = { ...l, stations: after }; linesChanged = true; } } const fares = DataService.getFares(); let faresChanged = false; for (let i = 0; i < fares.length; i++) { const f = fares[i]; if (!f) continue; const from = patchCode(f.from); const to = patchCode(f.to); if (from !== f.from || to !== f.to) { fares[i] = { ...f, from, to }; faresChanged = true; } } const orders = DataService.getOrders(); let ordersChanged = false; for (let i = 0; i < orders.length; i++) { const o = orders[i]; if (!o) continue; const start = patchCode(o.start); const terminal = patchCode(o.terminal); if (start !== o.start || terminal !== o.terminal) { orders[i] = { ...o, start, terminal }; ordersChanged = true; } } const orderIndex = DataService.getOrderIndex(); let orderIndexChanged = false; for (const k of Object.keys(orderIndex)) { const o = orderIndex[k]; if (!o) continue; const start = patchCode(o.start); const terminal = patchCode(o.terminal); if (start !== o.start || terminal !== o.terminal) { orderIndex[k] = { ...o, start, terminal }; orderIndexChanged = true; } } const ticketIndex = DataService.getTicketIndex(); let ticketIndexChanged = false; for (const k of Object.keys(ticketIndex)) { const t = ticketIndex[k]; if (!t) continue; const patched = { ...t, start: patchCode(t.start), terminal: patchCode(t.terminal), station_code: patchCode(t.station_code) }; if (patched.start !== t.start || patched.terminal !== t.terminal || patched.station_code !== t.station_code) { ticketIndex[k] = patched; ticketIndexChanged = true; } } await DataService.saveStations(all); io.emit('stations:updated', all); if (cfgChanged) await DataService.saveConfig(cfg); if (linesChanged) { await DataService.saveLines(lines); io.emit('lines:updated', lines); } if (faresChanged) { await DataService.saveFares(fares); io.emit('fares:updated', fares); } if (ordersChanged) await DataService.saveOrders(orders); if (orderIndexChanged) await DataService.saveOrderIndex(orderIndex); if (ticketIndexChanged) await DataService.saveTicketIndex(ticketIndex); appendReqLog(req, { category: 'admin', type: 'update_station', detail: { code: req.params.code, payload: incoming, renamed: renaming ? { from: oldCode, to: requestedCode } : null } }); res.json({ ok: true, renamed: renaming ? { from: oldCode, to: requestedCode } : null }); } catch (e) { appendReqLog(req, { category: 'system', type: 'update_station_failed', level: 'error', detail: { error: e?.message || String(e), payload: req.body } }); res.status(500).json({ ok: false, error: 'failed to save stations' }); } }); router.delete('/stations/:code', async (req, res) => { try { const code = req.params.code; let allStations = DataService.getStations(); const initialLen = allStations.length; allStations = allStations.filter(s => s.code !== code); if (allStations.length !== initialLen) { await DataService.saveStations(allStations); io.emit('stations:updated', allStations); } let allFares = DataService.getFares(); const initialFaresLen = allFares.length; allFares = allFares.filter(f => f.from !== code && f.to !== code); if (allFares.length !== initialFaresLen) { await DataService.saveFares(allFares); io.emit('fares:updated', allFares); } appendReqLog(req, { category: 'admin', type: 'delete_station', detail: { code } }); res.json({ ok: true }); } catch (e) { appendReqLog(req, { category: 'system', type: 'delete_station_failed', level: 'error', detail: { error: e?.message || String(e), code: req.params.code } }); res.status(500).json({ ok: false, error: 'failed to delete station' }); } }); // Lines router.get('/lines', (req, res) => res.json(DataService.getLines())); router.post('/lines', async (req, res) => { try { const all = DataService.getLines(); all.push(req.body); await DataService.saveLines(all); appendReqLog(req, { category: 'admin', type: 'add_line', detail: req.body }); io.emit('lines:updated', all); res.json({ ok: true }); } catch (e) { appendReqLog(req, { category: 'system', type: 'add_line_failed', level: 'error', detail: { error: e?.message || String(e), payload: req.body } }); res.status(500).json({ ok: false, error: 'failed to save lines' }); } }); router.put('/lines/:id', async (req, res) => { try { const all = DataService.getLines(); const idx = all.findIndex(l => l.id === req.params.id); if (idx < 0) return res.status(404).json({ ok: false, error: 'line not found' }); const incoming = req.body || {}; const current = all[idx] || {}; const oldId = String(current.id || '').trim(); const requestedId = (incoming.id == null) ? oldId : String(incoming.id || '').trim(); const renaming = requestedId && oldId && requestedId !== oldId; if (!requestedId) return res.status(400).json({ ok: false, error: 'line id required' }); if (renaming && all.some((l, i) => i !== idx && String(l.id || '').trim() === requestedId)) { return res.status(409).json({ ok: false, error: 'line id already exists' }); } all[idx] = { ...incoming, id: requestedId }; await DataService.saveLines(all); appendReqLog(req, { category: 'admin', type: 'update_line', detail: { id: req.params.id, payload: req.body, renamed: renaming ? { from: oldId, to: requestedId } : null } }); io.emit('lines:updated', all); res.json({ ok: true, renamed: renaming ? { from: oldId, to: requestedId } : null }); } catch (e) { appendReqLog(req, { category: 'system', type: 'update_line_failed', level: 'error', detail: { error: e?.message || String(e), payload: req.body } }); res.status(500).json({ ok: false, error: 'failed to save lines' }); } }); router.delete('/lines/:id', async (req, res) => { try { let all = DataService.getLines(); all = all.filter(l => l.id !== req.params.id); await DataService.saveLines(all); io.emit('lines:updated', all); appendReqLog(req, { category: 'admin', type: 'delete_line', detail: { id: req.params.id } }); res.json({ ok: true }); } catch (e) { appendReqLog(req, { category: 'system', type: 'delete_line_failed', level: 'error', detail: { error: e?.message || String(e), id: req.params.id } }); res.status(500).json({ ok: false, error: 'failed to delete line' }); } }); // Fares router.get('/fares', (req, res) => res.json(DataService.getFares())); router.post('/fares', async (req, res) => { try { const all = DataService.getFares(); const { from, to } = req.body || {}; if (!from || !to) return res.status(400).json({ ok: false, error: 'from/to required' }); const rest = all.filter(f => !(f.from === from && f.to === to)); const payload = { from, to, cost_regular: req.body.cost_regular ?? req.body.cost ?? 0, cost_express: req.body.cost_express ?? req.body.cost ?? 0 }; rest.push(payload); await DataService.saveFares(rest); appendReqLog(req, { category: 'admin', type: 'update_fare', detail: payload }); io.emit('fares:updated', rest); res.json({ ok: true }); } catch (e) { appendReqLog(req, { category: 'system', type: 'update_fare_failed', level: 'error', detail: { error: e?.message || String(e), payload: req.body } }); res.status(500).json({ ok: false, error: 'failed to save fares' }); } }); router.delete('/fares', async (req, res) => { try { const { from, to } = req.body; let all = DataService.getFares(); all = all.filter(f => !(f.from === from && f.to === to)); await DataService.saveFares(all); appendReqLog(req, { category: 'admin', type: 'delete_fare', detail: { from: req.body?.from, to: req.body?.to } }); io.emit('fares:updated', all); res.json({ ok: true }); } catch (e) { appendReqLog(req, { category: 'system', type: 'delete_fare_failed', level: 'error', detail: { error: e?.message || String(e), payload: req.body } }); res.status(500).json({ ok: false, error: 'failed to delete fare' }); } }); // Stats Ingest router.post('/stats/upload', async (req, res) => { const r = req.body || {}; if (!r.window_day && !r.window_hour) { appendReqLog(req, { category: 'device', type: 'stats_upload_invalid', level: 'warn', detail: { error: 'missing window_day/hour', payload: r } }); return res.status(400).json({ ok: false, error: 'missing window_day/hour' }); } const item = { device: r.device || 'unknown', station_code: r.station_code || '', station_name: r.station_name || '', sold_tickets: Number(r.sold_tickets || 0), sold_trips: Number(r.sold_trips || 0), revenue: Number(r.revenue || 0), ts: Number(r.ts || Date.now()), window_hour: String(r.window_hour || ''), window_day: String(r.window_day || ''), type: 'ticket' }; await DataService.appendStatTicket(item); io.emit('stats:ticket:updated', item); appendReqLog(req, { category: 'device', type: 'stats_upload', detail: item }); res.json({ ok: true }); }); router.post('/stats/ticket', async (req, res) => { const { device, station_code, station_name, sold_tickets, sold_trips, revenue, ts, window_hour, window_day } = req.body || {}; if(device !== 'ticket_machine') { appendReqLog(req, { category: 'device', type: 'stats_ticket_invalid', level: 'warn', detail: { error: 'device must be ticket_machine', payload: req.body } }); return res.status(400).json({ ok:false, error:'device must be ticket_machine' }); } const item = { device, station_code, station_name, sold_tickets: sold_tickets||0, sold_trips: sold_trips||0, revenue: revenue||0, ts: ts||Date.now(), window_hour, window_day }; await DataService.appendStatTicket(item); io.emit('stats:ticket:updated', item); appendReqLog(req, { category: 'device', type: 'stats_ticket', detail: item }); res.json({ ok:true }); }); router.post('/stats/gate', async (req, res) => { const { device, station_code, entries, exits, ts, window_hour, window_day } = req.body || {}; if(device !== 'gate') { appendReqLog(req, { category: 'device', type: 'stats_gate_invalid', level: 'warn', detail: { error: 'device must be gate', payload: req.body } }); return res.status(400).json({ ok:false, error:'device must be gate' }); } const item = { device, station_code, entries: entries||0, exits: exits||0, ts: ts||Date.now(), window_hour, window_day }; await DataService.appendStatGate(item); io.emit('stats:gate:updated', item); appendReqLog(req, { category: 'device', type: 'stats_gate', detail: item }); res.json({ ok:true }); }); router.get('/stats/ticket/total', (req, res) => { const all = DataService.getStatsTicket(); // Get local date string YYYY-MM-DD const now = new Date(); // Assuming server is in same timezone as operations (or China Standard Time UTC+8) // A simple hack for local YYYY-MM-DD const offset = now.getTimezoneOffset() * 60000; const localTime = new Date(now.getTime() - offset); const today = localTime.toISOString().split('T')[0]; const total = all.reduce((acc, cur) => { // Total overall stats acc.total_tickets += (cur.sold_tickets || 0); acc.total_revenue += (cur.revenue || 0); // Filter for today's stats if (cur.window_day === today) { acc.sold_tickets += (cur.sold_tickets || 0); acc.revenue += (cur.revenue || 0); } return acc; }, { sold_tickets: 0, revenue: 0, total_tickets: 0, total_revenue: 0 }); res.json({ ok: true, total }); }); // Logs router.get('/logs', async (req, res) => { const max = Number(req.query.max) || 200; const category = req.query.category; const type = req.query.type; const q = req.query.q; const since = req.query.since; const until = req.query.until; res.json({ ok: true, logs: await DataService.readLogs({ max, category, type, q, since, until }) }); }); router.post('/log', (req, res) => { const body = req.body || {}; const type = String(body.type || '').trim(); if (!type) return res.status(400).json({ ok: false, error: 'type required' }); appendReqLog(req, { category: body.category || body.source || 'admin', source: body.source, level: body.level, type, detail: body.detail }); res.json({ ok: true }); }); // Ticket Management (Admin) router.get('/tickets', (req, res) => { const q = String(req.query.q||'').trim().toLowerCase(); const idx = DataService.getTicketIndex(); let list = Object.entries(idx).map(([ticket_id, data])=>({ ticket_id, ...data })); if(q){ list = list.filter(t => t.ticket_id.toLowerCase().includes(q) || String(t.station_code||'').toLowerCase().includes(q) || String(t.start||'').toLowerCase().includes(q) || String(t.terminal||'').toLowerCase().includes(q)); } list.sort((a,b)=>Number(b.last_update_ts||0)-Number(a.last_update_ts||0)); list = list.map(t => { const tripsRemaining = (t.trips_remaining ?? t.rides_remaining); const tripsTotal = (t.trips_total ?? t.rides_total); const shouldBeUsed = (typeof tripsRemaining === 'number' && tripsRemaining <= 0) || ((tripsTotal == null || Number(tripsTotal) <= 1) && String(t.last_action || '') === 'exit'); const status = (t.status && t.status !== 'valid') ? t.status : (shouldBeUsed ? 'used' : (t.status || 'valid')); return { ...t, status }; }); res.json({ ok:true, tickets:list }); }); router.get('/tickets/:id', async (req, res) => { const id = normalizeTicketId(String(req.params.id||'').trim()); if(!id) return res.status(400).json({ ok:false, error:'ticket_id required' }); const idx = DataService.getTicketIndex(); const events = await DataService.getTicketEvents(id); const raw = idx[id] || {}; const tripsRemaining = (raw.trips_remaining ?? raw.rides_remaining); const tripsTotal = (raw.trips_total ?? raw.rides_total); const shouldBeUsed = (typeof tripsRemaining === 'number' && tripsRemaining <= 0) || ((tripsTotal == null || Number(tripsTotal) <= 1) && String(raw.last_action || '') === 'exit'); const status = (raw.status && raw.status !== 'valid') ? raw.status : (shouldBeUsed ? 'used' : (raw.status || 'valid')); res.json({ ok:true, ticket_id:id, index: { ...raw, status }, events }); }); // IC Card Management router.get('/ic-cards', async (req, res) => { const q = String(req.query.q || '').trim().toLowerCase(); const status = String(req.query.status || '').trim().toLowerCase(); const source = String(req.query.source || '').trim().toLowerCase(); let list = DataService.getIcCards().map((card) => ({ ...presentIcCard(card), status_label: mapIcCardStatus(card.status), card_type_label: mapIcCardType(card.card_type) })); if (q) { list = list.filter((card) => { const haystack = [ card.card_id, card.order_code, card.voucher_code, card.code, card.holder_name, card.source ].map((x) => String(x || '').toLowerCase()).join('\n'); return haystack.includes(q); }); } if (status) list = list.filter((card) => String(card.status || '').toLowerCase() === status); if (source) list = list.filter((card) => String(card.source || '').toLowerCase() === source); list.sort((a, b) => Number(b.last_update_ts || 0) - Number(a.last_update_ts || 0)); res.json({ ok: true, cards: list }); }); router.get('/ic-cards/:id', async (req, res) => { const id = normalizeIcCardId(req.params.id); if (!id) return res.status(400).json({ ok: false, error: 'card_id required' }); const card = DataService.getIcCardIndex()[id]; if (!card) return res.status(404).json({ ok: false, error: 'ic card not found' }); const events = await DataService.getIcCardEvents(id); res.json({ ok: true, card: { ...presentIcCard(card), status_label: mapIcCardStatus(card.status), card_type_label: mapIcCardType(card.card_type) }, events }); }); router.post('/ic-cards', async (req, res) => { try { const body = req.body || {}; const card_id = normalizeIcCardId(body.card_id) || buildIcCardId(); if (DataService.getIcCardIndex()[card_id]) { return res.status(409).json({ ok: false, error: 'ic card already exists' }); } const balance = Math.max(0, Number(body.balance ?? body.initial_balance ?? 0) || 0); const holder_name = String(body.holder_name || '').trim(); if (!holder_name) return res.status(400).json({ ok: false, error: 'holder_name required' }); if (!IC_CARD_HOLDER_NAME_RE.test(holder_name)) { return res.status(400).json({ ok: false, error: 'holder_name must use English letters and symbols only' }); } const now = Date.now(); const card = { card_id, holder_name, card_type: 'stored_value', status: String(body.status || 'active').trim(), balance, deposit: 0, source: String(body.source || 'admin').trim(), order_code: String(body.order_code || '').trim().toUpperCase(), purchase_amount: Math.max(0, Number(body.purchase_amount ?? balance) || 0), created_ts: now, activated_ts: now, last_update_ts: now }; await DataService.upsertIcCard(card); await DataService.appendIcCardEvent({ ts: now, type: 'create', card_id, operator: 'admin', detail: { balance, source: card.source, holder_name: card.holder_name } }); appendReqLog(req, { category: 'admin', type: 'ic_card_create', detail: { card_id, holder_name: card.holder_name, balance, source: card.source } }); res.json({ ok: true, card_id, display_card_id: displayIcCardId(card), card: presentIcCard(card) }); } catch (e) { appendReqLog(req, { category: 'system', type: 'ic_card_create_failed', level: 'error', detail: { error: e?.message || String(e), payload: req.body } }); res.status(500).json({ ok: false, error: 'failed to create ic card' }); } }); router.put('/ic-cards/:id', async (req, res) => { try { const id = normalizeIcCardId(req.params.id); if (!id) return res.status(400).json({ ok: false, error: 'card_id required' }); const current = DataService.getIcCardIndex()[id]; if (!current) return res.status(404).json({ ok: false, error: 'ic card not found' }); const body = req.body || {}; const nextHolderName = body.holder_name == null ? current.holder_name : String(body.holder_name || '').trim(); if (!nextHolderName) return res.status(400).json({ ok: false, error: 'holder_name required' }); if (!IC_CARD_HOLDER_NAME_RE.test(nextHolderName)) { return res.status(400).json({ ok: false, error: 'holder_name must use English letters and symbols only' }); } const patch = { holder_name: nextHolderName, card_type: 'stored_value', status: body.status == null ? current.status : String(body.status || 'active').trim(), deposit: 0 }; if (body.balance != null) patch.balance = Math.max(0, Number(body.balance) || 0); if (patch.status === 'active' && !current.activated_ts) patch.activated_ts = Date.now(); const card = await DataService.upsertIcCard({ ...current, ...patch, card_id: id }); await DataService.appendIcCardEvent({ ts: Date.now(), type: 'update', card_id: id, operator: 'admin', detail: patch }); appendReqLog(req, { category: 'admin', type: 'ic_card_update', detail: { card_id: id, patch } }); res.json({ ok: true, card: presentIcCard(card) }); } catch (e) { appendReqLog(req, { category: 'system', type: 'ic_card_update_failed', level: 'error', detail: { error: e?.message || String(e), card_id: req.params.id, payload: req.body } }); res.status(500).json({ ok: false, error: 'failed to update ic card' }); } }); router.delete('/ic-cards/:id', async (req, res) => { try { const id = normalizeIcCardId(req.params.id); if (!id) return res.status(400).json({ ok: false, error: 'card_id required' }); const current = DataService.getIcCardIndex()[id]; if (!current) return res.status(404).json({ ok: false, error: 'ic card not found' }); await DataService.appendIcCardEvent({ ts: Date.now(), type: 'delete', card_id: id, operator: 'admin', detail: { holder_name: current.holder_name || '', order_code: current.order_code || '', source: current.source || '' } }); await DataService.deleteIcCard(id); appendReqLog(req, { category: 'admin', type: 'ic_card_delete', detail: { card_id: id, holder_name: current.holder_name || '' } }); res.json({ ok: true, card_id: id }); } catch (e) { appendReqLog(req, { category: 'system', type: 'ic_card_delete_failed', level: 'error', detail: { error: e?.message || String(e), card_id: req.params.id } }); res.status(500).json({ ok: false, error: 'failed to delete ic card' }); } }); router.post('/ic-cards/:id/topup', async (req, res) => { try { const id = normalizeIcCardId(req.params.id); if (!id) return res.status(400).json({ ok: false, error: 'card_id required' }); const current = DataService.getIcCardIndex()[id]; if (!current) return res.status(404).json({ ok: false, error: 'ic card not found' }); const amount = Math.round((Number(req.body?.amount) || 0) * 100) / 100; if (!(amount > 0)) return res.status(400).json({ ok: false, error: 'topup amount must be greater than 0' }); const balance = Math.round(((Number(current.balance || 0) + amount) * 100)) / 100; const card = await DataService.upsertIcCard({ ...current, card_id: id, balance }); await DataService.appendIcCardEvent({ ts: Date.now(), type: 'topup', card_id: id, operator: 'admin', amount, balance }); appendReqLog(req, { category: 'admin', type: 'ic_card_topup', detail: { card_id: id, amount, balance } }); res.json({ ok: true, card: presentIcCard(card) }); } catch (e) { appendReqLog(req, { category: 'system', type: 'ic_card_topup_failed', level: 'error', detail: { error: e?.message || String(e), card_id: req.params.id, payload: req.body } }); res.status(500).json({ ok: false, error: 'failed to top up ic card' }); } }); router.post('/ic-cards/:id/sync', async (req, res) => { try { const id = normalizeIcCardId(req.params.id); if (!id) return res.status(400).json({ ok: false, error: 'card_id required' }); const current = DataService.getIcCardIndex()[id]; if (!current) return res.status(404).json({ ok: false, error: 'ic card not found' }); const body = req.body || {}; const resolveStation = buildStationResolver(); const ts = toFiniteNumberOrUndef(body.ts) ?? Date.now(); const syncTypeRaw = String(body.type || body.event_type || body.last_event || 'sync').trim().toLowerCase(); const syncType = syncTypeRaw === 'refill' ? 'topup' : (syncTypeRaw || 'sync'); const action = String(body.action || body.last_action || '').trim().toLowerCase(); const device = String(body.device || body.source || 'device').trim() || 'device'; const station_code = resolveCurrentStationCode(body, resolveStation); const entry_station = resolveStation(body.entry_station || body.start_station || current.entry_station || ''); const exit_station = resolveStation(body.exit_station || current.exit_station || ''); const balance = body.balance == null ? toMoney(current.balance) : Math.max(0, toMoney(body.balance)); const fare = body.fare == null ? toMoney(body.last_fare ?? current.last_fare ?? 0) : toMoney(body.fare); const amount = body.amount == null ? toFiniteNumberOrUndef(body.topup_amount ?? body.refill_amount) : toFiniteNumberOrUndef(body.amount); let entered = body.entered == null ? isTruthy(current.entered) : isTruthy(body.entered); let exited = body.exited == null ? isTruthy(current.exited) : isTruthy(body.exited); if (action === 'entry') { entered = true; exited = false; } else if (action === 'exit') { entered = false; exited = true; } const patch = { balance, entered, exited, entry_station: action === 'exit' ? entry_station : (entered ? (station_code || entry_station) : entry_station), exit_station: action === 'exit' ? (station_code || exit_station) : exit_station, last_fare: fare == null ? toMoney(current.last_fare ?? 0) : fare, last_action: action || (syncType === 'topup' ? 'topup' : current.last_action || ''), last_station_code: station_code || current.last_station_code || '', last_result: String(body.result || current.last_result || 'pass').trim() || 'pass', last_reason: String(body.reason || '').trim(), last_event: syncType }; if (action === 'entry') patch.entry_ts = ts; if (action === 'exit') patch.exit_ts = ts; const card = await DataService.upsertIcCard({ ...current, card_id: id, ...patch }); const event = { ts, type: syncType, card_id: id, action: action || undefined, result: patch.last_result, reason: patch.last_reason || undefined, station_code: station_code || undefined, entry_station: card.entry_station || undefined, exit_station: card.exit_station || undefined, fare: patch.last_fare, amount: amount == null ? undefined : toMoney(amount), balance: toMoney(card.balance), remaining_balance: toMoney(card.balance), device }; await DataService.appendIcCardEvent(event); io.emit(syncType === 'topup' ? 'ic-card:topup' : 'ic-card:sync', event); appendReqLog(req, { category: 'device', type: 'ic_card_sync', detail: event }); res.json({ ok: true, card: presentIcCard(card), event }); } catch (e) { appendReqLog(req, { category: 'system', type: 'ic_card_sync_failed', level: 'error', detail: { error: e?.message || String(e), card_id: req.params.id, payload: req.body } }); res.status(500).json({ ok: false, error: 'failed to sync ic card' }); } }); router.post('/cards/open', async (req, res) => { try { const body = req.body || {}; const now = Date.now(); const voucher_code = normalizeOrderCode(body.voucher_code || body.order_code || body.code); const device = String(body.device || 'ticket_machine').trim() || 'ticket_machine'; const station_code = String(body.station_code || '').trim(); const holder_name = String(body.holder_name || '').trim(); const note = String(body.note || '').trim(); const card_type = String(body.card_type || 'stored_value').trim() || 'stored_value'; const payment_mode = String(body.payment_mode || '').trim().toLowerCase(); const card_mode = String(body.card_mode || '').trim().toLowerCase(); if (voucher_code) { const current = findIcCardByOrderCode(voucher_code); if (!current) { appendReqLog(req, { category: 'device', type: 'card_open', detail: { ok: false, reason: 'not_found', voucher_code, device, payload: body } }); return res.status(404).json({ ok: false, error: 'ic card order not found' }); } const status = String(current.status || '').trim().toLowerCase(); if (status && status !== 'pending_pickup') { appendReqLog(req, { category: 'device', type: 'card_open', detail: { ok: false, reason: 'already_used', voucher_code, card_id: current.card_id, status, device } }); return res.status(409).json({ ok: false, error: 'card order already redeemed' }); } const current_card_id = normalizeIcCardId(current.card_id); const issued_card_id = normalizeIcCardId(body.card_id) || current_card_id || buildIcCardId(); const deposit = toMoney(body.deposit ?? current.deposit ?? 0); const balance = toMoney(body.balance ?? body.topup ?? current.balance ?? 0); const purchase_amount = toMoney(body.order_value ?? body.amount_paid ?? current.purchase_amount ?? (deposit + balance)); const amount_paid = toMoney(body.amount_paid); if (purchase_amount > 0 && amount_paid < purchase_amount) { appendReqLog(req, { category: 'device', type: 'card_open', detail: { ok: false, reason: 'payment_required', voucher_code, card_id: current.card_id, purchase_amount, amount_paid, device } }); return res.status(409).json({ ok: false, error: 'payment required before redeem' }); } const card = await DataService.upsertIcCard({ ...current, card_id: issued_card_id, holder_name: holder_name || current.holder_name || '', note: note || current.note || '', card_type: String(current.card_type || card_type).trim() || 'stored_value', status: 'active', balance, deposit, purchase_amount, source: String(current.source || 'online').trim() || 'online', payment_mode: payment_mode || current.payment_mode || 'online', card_mode: card_mode || current.card_mode || 'redeem', activated_ts: current.activated_ts || now, redeemed_ts: now, redeemed_station_code: station_code || current.redeemed_station_code || '', redeem_device: device, entered: false, exited: false, entry_station: '', exit_station: '', last_fare: 0, last_action: 'open', last_event: 'open', last_result: 'pass', last_reason: '' }); const event = { ts: now, type: 'redeem', card_id: card.card_id, order_code: voucher_code, station_code, device, balance, deposit, purchase_amount }; await DataService.appendIcCardEvent(event); if (current_card_id && current_card_id !== issued_card_id) { await DataService.deleteIcCard(current.card_id); } io.emit('ic-card:opened', { card_id: card.card_id, order_code: voucher_code, status: 'active' }); appendReqLog(req, { category: 'device', type: 'card_open', detail: { ok: true, mode: 'redeem', voucher_code, card_id: card.card_id, issued_card_id, station_code, device, balance, deposit } }); return res.json({ ok: true, mode: 'redeem', card_id: card.card_id, display_card_id: displayIcCardId(card), card: presentIcCard(card), data: presentIcCard(card) }); } const card_id = normalizeIcCardId(body.card_id) || buildIcCardId(); const current = DataService.getIcCardIndex()[card_id] || {}; const currentStatus = String(current.status || '').trim().toLowerCase(); if (current.card_id && currentStatus === 'active') { appendReqLog(req, { category: 'device', type: 'card_open', detail: { ok: false, reason: 'already_exists', card_id, device } }); return res.status(409).json({ ok: false, error: 'ic card already active' }); } const deposit = toMoney(body.deposit ?? current.deposit ?? 0); const balance = toMoney(body.balance ?? body.topup ?? current.balance ?? 0); const purchase_amount = toMoney(body.order_value ?? body.amount_paid ?? current.purchase_amount ?? (deposit + balance)); const card = await DataService.upsertIcCard({ ...current, card_id, order_code: normalizeOrderCode(body.order_code || current.order_code), holder_name: holder_name || current.holder_name || '', note: note || current.note || '', card_type: String(current.card_type || card_type).trim() || 'stored_value', status: 'active', balance, deposit, purchase_amount, source: String(body.source || current.source || 'ticket_machine').trim() || 'ticket_machine', payment_mode: payment_mode || current.payment_mode || 'local', card_mode: card_mode || current.card_mode || 'open', created_ts: current.created_ts || now, activated_ts: current.activated_ts || now, opened_ts: now, issue_station_code: station_code || current.issue_station_code || '', issue_device: device, entered: false, exited: false, entry_station: '', exit_station: '', last_fare: 0, last_action: 'open', last_event: 'open', last_result: 'pass', last_reason: '' }); const event = { ts: now, type: 'open', card_id, station_code, device, balance, deposit, purchase_amount }; await DataService.appendIcCardEvent(event); io.emit('ic-card:opened', { card_id, status: 'active' }); appendReqLog(req, { category: 'device', type: 'card_open', detail: { ok: true, mode: 'open', card_id, station_code, device, balance, deposit } }); res.json({ ok: true, mode: 'open', card_id, display_card_id: displayIcCardId(card), card: presentIcCard(card), data: presentIcCard(card) }); } catch (e) { appendReqLog(req, { category: 'system', type: 'card_open_failed', level: 'error', detail: { error: e?.message || String(e), payload: req.body } }); res.status(500).json({ ok: false, error: 'failed to open ic card' }); } }); router.post('/cards/check', async (req, res) => { try { const body = req.body || {}; const card_id = normalizeIcCardId(body.card_id); const action = String(body.action || '').trim().toLowerCase(); const device = String(body.device || 'gate').trim() || 'gate'; const ts = toFiniteNumberOrUndef(body.ts) ?? Date.now(); if (!card_id || !action) return res.status(400).json({ ok: false, error: 'card_id and action required' }); if (action !== 'entry' && action !== 'exit') return res.status(400).json({ ok: false, error: 'action must be entry/exit' }); const resolveStation = buildStationResolver(); const station_code = resolveCurrentStationCode(body, resolveStation); const current = DataService.getIcCardIndex()[card_id]; const hintedBalance = toFiniteNumberOrUndef(body.balance); const deny = async (reason, extra = {}) => { const detail = { ts, type: 'check', card_id, action, result: 'deny', reason, station_code, device, ...extra }; await DataService.appendIcCardEvent(detail); if (current) { await DataService.upsertIcCard({ ...current, last_action: action, last_station_code: station_code || current.last_station_code || '', last_result: 'deny', last_reason: reason, last_event: 'check' }); } io.emit('ic-card:check', detail); appendReqLog(req, { category: 'device', type: 'card_check', detail }); return res.json({ ok: true, card_id, action, result: 'deny', reason, station_code, ...extra }); }; if (!current) return deny('not_found'); const status = String(current.status || '').trim().toLowerCase(); if (status && status !== 'active') return deny(`status_${status}`); if (!station_code) return deny('missing_station'); const entered = isTruthy(current.entered != null ? current.entered : body.entered); const exited = isTruthy(current.exited != null ? current.exited : body.exited); const currentBalance = toMoney(current.balance ?? hintedBalance ?? 0); if (action === 'entry') { if (entered && !exited) return deny('already_entered', { balance: currentBalance, remaining_balance: currentBalance }); const card = await DataService.upsertIcCard({ ...current, entered: true, exited: false, entry_station: station_code, exit_station: '', last_fare: 0, last_action: 'entry', last_station_code: station_code, last_result: 'pass', last_reason: '', last_event: 'check', entry_ts: ts }); const event = { ts, type: 'check', card_id, action, result: 'pass', station_code, entry_station: station_code, device, balance: toMoney(card.balance) }; await DataService.appendIcCardEvent(event); io.emit('ic-card:check', event); appendReqLog(req, { category: 'device', type: 'card_check', detail: event }); return res.json({ ok: true, card_id, action, result: 'pass', station_code, entry_station: station_code, balance: toMoney(card.balance), remaining_balance: toMoney(card.balance), fare: 0 }); } if (!entered) return deny('not_entered', { balance: currentBalance, remaining_balance: currentBalance }); if (exited) return deny('already_exited', { balance: currentBalance, remaining_balance: currentBalance }); const entry_station = resolveStation(current.entry_station || body.entry_station || body.start_station || ''); if (!entry_station) return deny('missing_entry_station', { balance: currentBalance, remaining_balance: currentBalance }); const fareInfo = LogicService.computeFareBoth(entry_station, station_code); const fareRaw = toFiniteNumberOrUndef(fareInfo?.regular) ?? toFiniteNumberOrUndef(fareInfo?.express); if (fareRaw == null) return deny('fare_not_found', { balance: currentBalance, remaining_balance: currentBalance, entry_station, exit_station: station_code }); const fare = toMoney(fareRaw); if (currentBalance < fare) { return deny('insufficient_balance', { balance: currentBalance, remaining_balance: currentBalance, fare, entry_station, exit_station: station_code }); } const nextBalance = toMoney(currentBalance - fare); const card = await DataService.upsertIcCard({ ...current, balance: nextBalance, entered: false, exited: true, exit_station: station_code, last_fare: fare, last_action: 'exit', last_station_code: station_code, last_result: 'pass', last_reason: '', last_event: 'check', exit_ts: ts }); const event = { ts, type: 'check', card_id, action, result: 'pass', station_code, entry_station, exit_station: station_code, fare, balance: nextBalance, remaining_balance: nextBalance, device }; await DataService.appendIcCardEvent(event); io.emit('ic-card:check', event); appendReqLog(req, { category: 'device', type: 'card_check', detail: event }); return res.json({ ok: true, card_id, action, result: 'pass', station_code, entry_station, exit_station: station_code, fare, balance: nextBalance, remaining_balance: nextBalance }); } catch (e) { appendReqLog(req, { category: 'system', type: 'card_check_failed', level: 'error', detail: { error: e?.message || String(e), payload: req.body } }); res.status(500).json({ ok: false, error: 'failed to check ic card' }); } }); // Export router.get('/export', (req, res) => { res.json(DataService.buildExportPayload()); }); // Ticket Operations router.post('/tickets/sale', async (req, res) => { const { start, terminal, train_type, cost, station_code, device, trips_total, trips_remaining } = req.body || {}; const ticket_id = normalizeTicketId((req.body || {}).ticket_id); if (!ticket_id) return res.status(400).json({ ok: false, error: 'ticket_id required' }); const toFiniteNumberOrUndef = (v) => { if (v == null) return undefined; const n = Number(v); return Number.isFinite(n) ? n : undefined; }; const tTotal = toFiniteNumberOrUndef(trips_total); const tRemain = toFiniteNumberOrUndef(trips_remaining); const ev = { ts: Date.now(), type: 'sale', ticket_id, start, terminal, train_type, cost: cost || 0, station_code: station_code || '', device: device || 'unknown', trips_total: tTotal, trips_remaining: tRemain }; await DataService.appendTicketEvent(ev); await DataService.upsertTicketIndex({ ticket_id, start, terminal, train_type, cost: cost || 0, status: 'valid', station_code, last_event: 'sale', trips_total: tTotal, trips_remaining: tRemain, last_update_ts: Date.now() }); const now = new Date(); const statItem = { device: device || 'unknown', station_code: station_code || '', sold_tickets: 1, revenue: cost || 0, ts: Date.now(), window_hour: now.getHours().toString().padStart(2, '0'), window_day: now.toISOString().split('T')[0], type: 'ticket' }; await DataService.appendStatTicket(statItem); io.emit('ticket:sale', ev); io.emit('stats:ticket:updated', statItem); appendReqLog(req, { category: 'device', type: 'ticket_sale', detail: { ...ev, trips_total: tTotal, trips_remaining: tRemain } }); res.json({ ok: true, ticket_id }); }); router.post('/tickets/check', async (req, res) => { const body = req.body || {}; const ticket_id = normalizeTicketId(body.ticket_id); const action = String(body.action || '').trim().toLowerCase(); const device = String(body.device || 'gate'); const tsIn = Number(body.ts); const ts = Number.isFinite(tsIn) ? tsIn : Date.now(); const hintTripsTotal = body.trips_total; const hintTripsRemaining = body.trips_remaining; const normStationKey = (v) => String(v || '').trim().toUpperCase().replace(/\s+/g, ''); const buildStationResolver = () => { const stations = DataService.getStations?.() || []; const codeByKey = new Map(); const nameToCode = new Map(); const looksLikeStationCode = (v) => /^[A-Z0-9]+(?:-[A-Z0-9]+)+$/i.test(String(v || '').trim()); for (const s of stations) { if (!s) continue; const code = String(s.code || '').trim(); if (!code) continue; codeByKey.set(normStationKey(code), code); const cn = String(s.name || s.cn_name || s.station_name || '').trim(); if (cn) nameToCode.set(normStationKey(cn), code); const en = String(s.en_name || s.en || s.enName || '').trim(); if (en) nameToCode.set(normStationKey(en), code); } return (v) => { const raw = String(v || '').trim(); if (!raw) return ''; const k = normStationKey(raw); if (codeByKey.has(k)) return codeByKey.get(k); if (looksLikeStationCode(raw)) return raw; return nameToCode.get(k) || raw; }; }; const resolveStation = buildStationResolver(); const station_code_in = String(body.station_code || '').trim(); const station_code_raw = resolveStation(station_code_in); const station_code_norm = normStationKey(station_code_raw); const station_codes = Array.isArray(body.station_codes) ? body.station_codes.map(x => resolveStation(x)).filter(Boolean) : []; const station_codes_norm = new Set(station_codes.map(normStationKey).filter(Boolean)); if (!ticket_id || !action) return res.status(400).json({ ok: false, error: 'ticket_id and action required' }); if (action !== 'entry' && action !== 'exit') return res.status(400).json({ ok: false, error: 'action must be entry/exit' }); const idx = DataService.getTicketIndex(); const cur = idx[ticket_id]; const deny = async (reason) => { const ev = { ts, type: 'check', ticket_id, action, result: 'deny', reason, station_code: station_code_raw || '', device }; await DataService.appendTicketEvent(ev); await DataService.upsertTicketIndex({ ticket_id, last_action: action, last_station_code: station_code_raw || '', last_result: 'deny', last_reason: reason, last_event: 'check', last_update_ts: Date.now() }); io.emit('ticket:check', ev); appendReqLog(req, { category: 'device', type: 'ticket_check', detail: ev }); return res.json({ ok: true, ticket_id, action, result: 'deny', reason }); }; if (!cur) return deny('not_found'); if (cur.status && cur.status !== 'valid') return deny(`status_${cur.status}`); const start = resolveStation(cur.start); const terminal = resolveStation(cur.terminal); if (!start || !terminal) return deny('missing_route'); const matchesStation = (code) => { const c = resolveStation(code); const ck = normStationKey(c); if (!ck) return false; if (station_codes_norm.size > 0) return station_codes_norm.has(ck); if (station_code_norm) return station_code_norm === ck; return true; }; const isTruthy = (v) => v === true || v === 1 || v === '1' || String(v || '').toLowerCase() === 'true'; const entered = isTruthy(cur.entered); const exited = isTruthy(cur.exited); const toFiniteNumberOrUndef = (v) => { if (v == null) return undefined; const n = Number(v); return Number.isFinite(n) ? n : undefined; }; const tripsTotal = toFiniteNumberOrUndef(cur.trips_total) ?? toFiniteNumberOrUndef(cur.rides_total) ?? toFiniteNumberOrUndef(hintTripsTotal) ?? 1; const tripsRemain0 = toFiniteNumberOrUndef(cur.trips_remaining) ?? toFiniteNumberOrUndef(cur.rides_remaining) ?? toFiniteNumberOrUndef(hintTripsRemaining); const tripsRemainEffective = (tripsRemain0 == null) ? tripsTotal : tripsRemain0; if (action === 'entry') { if (!matchesStation(start)) return deny('wrong_station'); if (entered && !exited) return deny('already_entered'); const ev = { ts, type: 'check', ticket_id, action, result: 'pass', station_code: start, device }; await DataService.appendTicketEvent(ev); await DataService.upsertTicketIndex({ ticket_id, entered: true, exited: false, last_action: 'entry', last_station_code: start, last_result: 'pass', last_reason: '', last_event: 'check', trips_total: tripsTotal, trips_remaining: tripsRemainEffective, last_update_ts: Date.now() }); io.emit('ticket:check', ev); appendReqLog(req, { category: 'device', type: 'ticket_check', detail: ev }); return res.json({ ok: true, ticket_id, action, result: 'pass', station_code: start, trips_remaining: tripsRemainEffective, destroy_ticket: false }); } if (!matchesStation(terminal)) return deny('wrong_station'); if (!entered) return deny('not_entered'); if (exited) return deny('already_exited'); const newTripsRemaining = Math.max(0, (Number(tripsRemainEffective) || 0) - 1); const shouldSetUsed = newTripsRemaining <= 0; const ev = { ts, type: 'check', ticket_id, action, result: 'pass', station_code: terminal, device, trips_remaining: newTripsRemaining }; await DataService.appendTicketEvent(ev); await DataService.upsertTicketIndex({ ticket_id, exited: true, last_action: 'exit', last_station_code: terminal, last_result: 'pass', last_reason: '', last_event: 'check', trips_total: tripsTotal, trips_remaining: newTripsRemaining, status: shouldSetUsed ? 'used' : (cur.status || 'valid'), last_update_ts: Date.now() }); io.emit('ticket:check', ev); appendReqLog(req, { category: 'device', type: 'ticket_check', detail: ev }); return res.json({ ok: true, ticket_id, action, result: 'pass', station_code: terminal, trips_remaining: newTripsRemaining, destroy_ticket: shouldSetUsed }); }); router.post('/tickets/status', async (req, res) => { const { action, station_code, device, result, reason } = req.body || {}; const ticket_id = normalizeTicketId((req.body || {}).ticket_id); const ridesRemainingRaw = (req.body || {}).rides_remaining; const tripsRemainingRaw = (req.body || {}).trips_remaining; const toFiniteNumberOrUndef = (v) => { if (v == null) return undefined; const n = Number(v); return Number.isFinite(n) ? n : undefined; }; const rides_remaining = toFiniteNumberOrUndef(ridesRemainingRaw); const trips_remaining = (tripsRemainingRaw == null) ? rides_remaining : toFiniteNumberOrUndef(tripsRemainingRaw); const tsIn = Number((req.body || {}).ts); const ts = Number.isFinite(tsIn) ? tsIn : Date.now(); if (!ticket_id || !action) return res.status(400).json({ ok: false, error: 'ticket_id and action required' }); const ev = { ts, type: 'status', ticket_id, action, result, reason, station_code: station_code || '', device: device || 'unknown', trips_remaining, rides_remaining }; await DataService.appendTicketEvent(ev); const shouldSetUsed = String(action) === 'exit' && String(result || '') === 'pass' && typeof trips_remaining === 'number' && trips_remaining <= 0; const cur = DataService.getTicketIndex()[ticket_id] || {}; const status = (cur.status && cur.status !== 'valid') ? cur.status : (shouldSetUsed ? 'used' : (cur.status || 'valid')); await DataService.upsertTicketIndex({ ticket_id, last_action: action, last_station_code: station_code, last_result: result, last_reason: reason, trips_remaining, rides_remaining, last_event: 'status', status, last_update_ts: Date.now() }); io.emit('ticket:status', ev); appendReqLog(req, { category: 'device', type: 'ticket_status', detail: ev }); res.json({ ok: true, ticket_id }); }); // Voucher Detail router.get('/orders', (req, res) => { const list = DataService.getOrders(); const map = LogicService.buildStationNameMap(); const nameFor = (c) => (map && map[c]) || c; // Enrich with status if not present (default: consumed=false means Available, but check expiry?) // Actually, logic for expiry isn't clear. Assuming 'valid' if !consumed. const enriched = list.map(o => ({ ...o, start_name: nameFor(o.start), terminal_name: nameFor(o.terminal), status: o.consumed ? 'used' : (o.expired ? 'expired' : 'valid') // simple logic })).reverse(); // Newest first res.json({ ok: true, orders: enriched }); }); router.delete('/orders/:code', async (req, res) => { const code = req.params.code; let list = DataService.getOrders(); const initialLen = list.length; list = list.filter(o => o.code !== code); if (list.length !== initialLen) { await DataService.saveOrders(list); // Also remove from index const idx = DataService.getOrderIndex(); delete idx[code]; await DataService.saveOrderIndex(idx); io.emit('order:deleted', { code }); } appendReqLog(req, { category: 'admin', type: 'delete_order', detail: { code, deleted: list.length !== initialLen } }); res.json({ ok: true }); }); module.exports = router;