Files
Henry_Du 07e4200c17 feat(web,backend,lua,installer): 新增Lua脚本版本管理功能及相关优化
- 升级售票机、检票机内置Lua脚本版本至v1.5.8
- 新增后端配置的lua_versions字段,统一管理售票机、检票机的Lua脚本版本
- 前端新增版本管理配置页面,支持版本号配置和一键补丁升级
- 为售票机、检票机添加远程版本检测功能,屏幕显示版本匹配状态标记
- 简化installer配置交互流程,优化站点代码输入方式
- 重构后端配置规范化处理逻辑,统一配置初始化与存储流程
- 优化售票机外设检测、支付检测逻辑,修复部分已知问题
2026-06-28 16:30:17 +08:00

1519 lines
60 KiB
JavaScript

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;