2026-06-21 10:00:13 +08:00
|
|
|
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) => {
|
2026-06-28 16:30:17 +08:00
|
|
|
const cfg = DataService.getConfig();
|
2026-06-21 10:00:13 +08:00
|
|
|
res.json({
|
2026-06-28 16:30:17 +08:00
|
|
|
api_base: cfg.api_base,
|
|
|
|
|
current_station: cfg.current_station,
|
2026-06-21 10:00:13 +08:00
|
|
|
stations: DataService.getStations(),
|
|
|
|
|
lines: DataService.getLines(),
|
|
|
|
|
fares: DataService.getFares(),
|
2026-06-28 16:30:17 +08:00
|
|
|
transfers: cfg.transfers || [],
|
|
|
|
|
promotion: cfg.promotion || { name: '', discount: 1 },
|
|
|
|
|
lua_versions: cfg.lua_versions || {}
|
2026-06-21 10:00:13 +08:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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 };
|
|
|
|
|
}
|
2026-06-28 16:30:17 +08:00
|
|
|
if (incoming.lua_versions && typeof incoming.lua_versions === 'object') {
|
|
|
|
|
cfg.lua_versions = {
|
|
|
|
|
...(cfg.lua_versions || {}),
|
|
|
|
|
...(incoming.lua_versions || {})
|
|
|
|
|
};
|
|
|
|
|
}
|
2026-06-21 10:00:13 +08:00
|
|
|
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;
|