feat: polish BI dashboards and bump version
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
lingniu
2026-06-27 21:59:33 +08:00
parent 5377d2c225
commit b0caa5afcb
33 changed files with 2363 additions and 483 deletions
+188
View File
@@ -468,6 +468,59 @@ function getStats(list: Vehicle[], weeklyIds?: WeeklyTruckIds) {
const WEEK_START_SQL = `DATE_SUB(CURDATE(), INTERVAL (WEEKDAY(CURDATE()) + 2) % 7 DAY)`;
const WEEK_END_SQL = `DATE_ADD(${WEEK_START_SQL}, INTERVAL 7 DAY)`;
type FlowType = 'delivered' | 'returned' | 'replaced';
interface FlowDetailRow {
id: string;
type: FlowType;
type_label: string;
stat_date: string;
truck_id: string;
plate_number: string;
event_time: string | null;
submit_time: string | null;
department: string | null;
manager: string | null;
customer_name: string | null;
}
function formatDateOnly(value: Date): string {
const y = value.getFullYear();
const m = String(value.getMonth() + 1).padStart(2, '0');
const d = String(value.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function isDateParam(value: string | undefined | null): value is string {
return Boolean(value && /^\d{4}-\d{2}-\d{2}$/.test(value));
}
function addDateDays(date: string, days: number): string {
const d = new Date(`${date}T00:00:00`);
d.setDate(d.getDate() + days);
return formatDateOnly(d);
}
function listDateRange(start: string, end: string): string[] {
const dates: string[] = [];
let cursor = start;
while (cursor <= end && dates.length <= 370) {
dates.push(cursor);
cursor = addDateDays(cursor, 1);
}
return dates;
}
function normalizeDateRange(startRaw: string | undefined, endRaw: string | undefined): { start: string; end: string } {
const today = formatDateOnly(new Date());
const defaultStart = addDateDays(today, -29);
let start = isDateParam(startRaw) ? startRaw : defaultStart;
let end = isDateParam(endRaw) ? endRaw : today;
if (start > end) [start, end] = [end, start];
if (listDateRange(start, end).length > 370) start = addDateDays(end, -369);
return { start, end };
}
interface WeeklyStats {
pendingDelivery: number;
weeklyNew: number;
@@ -1122,6 +1175,141 @@ app.get('/weekly-detail', async (c) => {
return c.json(masked);
});
// GET /api/vehicles/flow-stats?start=YYYY-MM-DD&end=YYYY-MM-DD
// 资产流转日报:按提交时间(create_time)统计交车、还车、替换车,并返回可点击明细。
app.get('/flow-stats', async (c) => {
const { start, end } = normalizeDateRange(c.req.query('start'), c.req.query('end'));
const allowedVehicles = await getVehiclesForUser(c);
const allowedTruckIds = new Set(allowedVehicles.map((v) => String(v.id)));
const sql = `
SELECT *
FROM (
SELECT
CONCAT('delivered-', dv.id) AS id,
'delivered' AS type,
'交车' AS type_label,
DATE_FORMAT(dv.create_time, '%Y-%m-%d') AS stat_date,
CAST(dv.vehicle_id AS CHAR) AS truck_id,
dv.plate_number,
DATE_FORMAT(dv.delivery_time, '%Y-%m-%d %H:%i:%s') AS event_time,
DATE_FORMAT(dv.create_time, '%Y-%m-%d %H:%i:%s') AS submit_time,
c.business_department_name AS department,
c.business_manager_name AS manager,
COALESCE(dts.customer_name, c.customer_name) AS customer_name
FROM delivery_vehicle dv
LEFT JOIN delivery_task_subject dts
ON dts.id = dv.delivery_task_subject_id
AND dts.del_flag = '0'
LEFT JOIN vehicle_lease_contract_info c
ON c.order_id = dv.contract_id
AND c.del_flag = '0'
WHERE dv.del_flag = '0'
AND dv.vehicle_id IS NOT NULL
AND dv.create_time IS NOT NULL
AND dv.delivery_status IN (2,3,5)
AND dv.create_time >= ?
AND dv.create_time < DATE_ADD(?, INTERVAL 1 DAY)
UNION ALL
SELECT
CONCAT('returned-', r.id) AS id,
'returned' AS type,
'还车' AS type_label,
DATE_FORMAT(r.create_time, '%Y-%m-%d') AS stat_date,
CAST(r.vehicle_id AS CHAR) AS truck_id,
r.plate_number,
DATE_FORMAT(r.arrival_time, '%Y-%m-%d %H:%i:%s') AS event_time,
DATE_FORMAT(r.create_time, '%Y-%m-%d %H:%i:%s') AS submit_time,
c.business_department_name AS department,
c.business_manager_name AS manager,
COALESCE(dts.customer_name, c.customer_name) AS customer_name
FROM return_vehicle_task r
LEFT JOIN delivery_task_subject dts
ON dts.id = r.delivery_task_subject_id
AND dts.del_flag = '0'
LEFT JOIN vehicle_lease_contract_info c
ON c.order_id = r.contract_id
AND c.del_flag = '0'
WHERE r.del_flag = '0'
AND r.vehicle_id IS NOT NULL
AND r.create_time IS NOT NULL
AND r.status IN (2,3,5)
AND r.create_time >= ?
AND r.create_time < DATE_ADD(?, INTERVAL 1 DAY)
UNION ALL
SELECT
CONCAT('replaced-', vr.id) AS id,
'replaced' AS type,
'替换' AS type_label,
DATE_FORMAT(vr.create_time, '%Y-%m-%d') AS stat_date,
CAST(vr.new_vehicle_id AS CHAR) AS truck_id,
vr.new_vehicle_plate AS plate_number,
DATE_FORMAT(vr.replace_time, '%Y-%m-%d %H:%i:%s') AS event_time,
DATE_FORMAT(vr.create_time, '%Y-%m-%d %H:%i:%s') AS submit_time,
c.business_department_name AS department,
c.business_manager_name AS manager,
COALESCE(dts.customer_name, c.customer_name) AS customer_name
FROM vehicle_replacement vr
LEFT JOIN delivery_task_subject dts
ON dts.id = vr.delivery_task_subject_id
AND dts.del_flag = '0'
LEFT JOIN vehicle_lease_contract_info c
ON c.id = vr.contract_id
AND c.del_flag = '0'
WHERE vr.del_flag = '0'
AND vr.new_vehicle_id IS NOT NULL
AND vr.create_time IS NOT NULL
AND vr.status = 20
AND vr.create_time >= ?
AND vr.create_time < DATE_ADD(?, INTERVAL 1 DAY)
) flow
ORDER BY flow.submit_time DESC
`;
const [rows] = await pool.query<any[]>(sql, [start, end, start, end, start, end]);
const details = (rows as FlowDetailRow[])
.filter((row) => allowedTruckIds.has(String(row.truck_id)))
.map((row) => ({
id: row.id,
type: row.type,
typeLabel: row.type_label,
date: row.stat_date,
truckId: row.truck_id,
plateNumber: row.plate_number,
eventTime: row.event_time,
submitTime: row.submit_time,
department: row.department || '',
manager: row.manager || '',
customerName: maskCustomerName(row.customer_name),
}));
const dailyMap = new Map<string, { date: string; delivered: number; returned: number; replaced: number; total: number }>();
for (const date of listDateRange(start, end)) dailyMap.set(date, { date, delivered: 0, returned: 0, replaced: 0, total: 0 });
for (const item of details) {
const stat = dailyMap.get(item.date);
if (!stat) continue;
stat[item.type] += 1;
stat.total += 1;
}
const daily = Array.from(dailyMap.values());
const totals = daily.reduce(
(acc, item) => ({
delivered: acc.delivered + item.delivered,
returned: acc.returned + item.returned,
replaced: acc.replaced + item.replaced,
total: acc.total + item.total,
}),
{ delivered: 0, returned: 0, replaced: 0, total: 0 },
);
return c.json({ start, end, daily, totals, details });
});
// GET /api/vehicles/subjects — 归属公司列表(含台数预览),用于顶部筛选下拉
app.get('/subjects', async (c) => {
const all = await getVehicles();