Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c13ab6d3b | ||
|
|
1e4afacac7 | ||
|
|
0c26b973ef |
@@ -0,0 +1,198 @@
|
||||
# OneOS 日里程与累计里程差额排查报告
|
||||
|
||||
- 排查日期:2026-09-16(Asia/Shanghai)
|
||||
- 涉及数据日期:2026-09-11 至 2026-09-15
|
||||
- 接收方:OneOS 开放平台及里程计算服务维护人员
|
||||
- 状态:已复现;待上游核查计算基准、跨日缺报及协议选源逻辑
|
||||
|
||||
## 1. 问题概述
|
||||
|
||||
BI 用户发现:昨日累计总里程减去前日累计总里程,不等于昨日的区间里程;继续向前检查,多日均存在差额。
|
||||
|
||||
本次在相同车辆范围、相同协议优先级下复现,并绕过 BI 直接请求 OneOS 单日接口核对异常车辆。上游返回本身就存在相邻日期累计差与日里程不一致的情况,不能仅用 BI 显示取整解释。
|
||||
|
||||
已确认的现象:
|
||||
|
||||
1. 某查询日返回的累计里程对应更早日期的报文;后续更新后的累计差跨越多天,与当日日里程不一致。
|
||||
2. 同一车辆相邻日期切换 MQTT / GB32960,累计读数发生跳变,且切换后可能选中很早的读数。
|
||||
|
||||
尚未确认:日里程的内部计算公式、有效数据判定、跨日补算规则及异常过滤规则。本文不将差额直接认定为漏算里程,也不将累计差直接认定为真实日行驶里程。
|
||||
|
||||
## 2. 查询范围与 BI 计算口径
|
||||
|
||||
本次 BI 查询未附加客户、部门、车牌或里程筛选条件,每天返回 916 台车辆。
|
||||
|
||||
```text
|
||||
GET /api/mileage/monitoring
|
||||
?startDate=2026-09-15
|
||||
&endDate=2026-09-15
|
||||
&sourcePriority=instrument
|
||||
&limit=2000
|
||||
```
|
||||
|
||||
`instrument` 对应上游协议优先级:
|
||||
|
||||
```json
|
||||
["GB32960", "MQTT"]
|
||||
```
|
||||
|
||||
BI 当前计算方式:
|
||||
|
||||
| 指标 | 计算方式 |
|
||||
| --- | --- |
|
||||
| 单车累计里程 | OneOS `totalMileageKm`,不以日里程反推 |
|
||||
| 单车单日里程 | OneOS `dailyMileageKm` |
|
||||
| 单车区间里程 | 区间内每日 `dailyMileageKm` 求和 |
|
||||
| 累计总里程 | 查询范围内车辆的累计里程求和;空值按 0 参与汇总 |
|
||||
| 当日总里程 | 查询范围内车辆的日里程求和;汇总接口对结果取整 |
|
||||
|
||||
以下对账表使用逐车日里程原始数值求和,保留三位小数,避免整数显示带来的误差。
|
||||
|
||||
## 3. 汇总对账结果
|
||||
|
||||
单位:km。定义 `差额 = 当天累计总里程 - 前一天累计总里程 - 当天日里程合计`。
|
||||
|
||||
| 日期 | 累计总里程 | 相邻日累计差 | 当日日里程合计 | 差额 |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| 2026-09-11 | 48,448,486.800 | — | 98,182.208 | — |
|
||||
| 2026-09-12 | 48,546,128.300 | 97,641.500 | 97,321.774 | +319.726 |
|
||||
| 2026-09-13 | 48,619,784.800 | 73,656.500 | 82,322.847 | −8,666.347 |
|
||||
| 2026-09-14 | 48,721,190.600 | 101,405.800 | 94,242.687 | +7,163.113 |
|
||||
| 2026-09-15 | 48,824,917.800 | 103,727.200 | 103,554.604 | +172.596 |
|
||||
|
||||
按车牌匹配相邻日期,差额来源分解如下:
|
||||
|
||||
| 日期 | 两日累计非空且协议相同的车辆数 / 净差额 | 两日累计非空且协议变化的车辆数 / 净差额 | 至少一日累计为空的车辆数 / 净差额 |
|
||||
| --- | --- | --- | --- |
|
||||
| 09-12 | 759 / +319.726 | 0 / 0 | 157 / 0 |
|
||||
| 09-13 | 758 / +674.653 | 1 / −9,341.000 | 157 / 0 |
|
||||
| 09-14 | 759 / +7,163.113 | 0 / 0 | 157 / 0 |
|
||||
| 09-15 | 759 / +172.596 | 0 / 0 | 157 / 0 |
|
||||
|
||||
本次样本中,空累计值组对差额净贡献为 0;同协议数据也存在明显不一致,不能只修复协议切换问题。
|
||||
|
||||
## 4. 异常样本一:累计读数跨日,日里程未与累计差对齐
|
||||
|
||||
以下记录已通过 OneOS 单日接口直接复核,响应均为 HTTP 200、`code=SUCCESS`,记录状态均为 `NORMAL`。
|
||||
|
||||
### 4.1 粤AGQ8377:9 月 15 日差额 +70.2 km
|
||||
|
||||
| 查询日期 | sourceProtocol | totalMileageKm | dailyMileageKm | dataTime(+08:00) |
|
||||
| --- | --- | ---: | ---: | --- |
|
||||
| 2026-09-13 | GB32960 | 14,366.0 | 6.1 | 2026-09-13 07:17:27 |
|
||||
| 2026-09-14 | GB32960 | 14,366.0 | 0 | 2026-09-13 07:17:27 |
|
||||
| 2026-09-15 | GB32960 | 14,467.4 | 31.2 | 2026-09-15 20:57:04 |
|
||||
|
||||
```text
|
||||
累计差 = 14,467.4 - 14,366.0 = 101.4
|
||||
差额 = 101.4 - 31.2 = 70.2 km
|
||||
```
|
||||
|
||||
查询 9 月 14 日时,累计读数仍来自 9 月 13 日。请核查 9 月 15 日日里程的起点读数及其时间,并解释 70.2 km 在日统计中的归属或过滤原因。
|
||||
|
||||
### 4.2 粤AGR6839:9 月 15 日差额 +38.0 km
|
||||
|
||||
| 查询日期 | sourceProtocol | totalMileageKm | dailyMileageKm | dataTime(+08:00) |
|
||||
| --- | --- | ---: | ---: | --- |
|
||||
| 2026-09-14 | GB32960 | 45,122.2 | 0 | 2026-09-11 11:10:53 |
|
||||
| 2026-09-15 | GB32960 | 45,162.8 | 2.6 | 2026-09-15 10:34:02 |
|
||||
|
||||
累计差为 40.6 km,日里程为 2.6 km,差额为 38.0 km。请核查跨多日无新读数后的统计基准及补算策略。
|
||||
|
||||
### 4.3 粤AGP3609:9 月 14 日差额 +808.7 km
|
||||
|
||||
| 查询日期 | sourceProtocol | totalMileageKm | dailyMileageKm | dataTime(+08:00) |
|
||||
| --- | --- | ---: | ---: | --- |
|
||||
| 2026-09-12 | GB32960 | 48,102.9 | 1,293.6 | 2026-09-12 15:08:47 |
|
||||
| 2026-09-13 | GB32960 | 48,102.9 | 0 | 2026-09-12 15:08:47 |
|
||||
| 2026-09-14 | GB32960 | 48,981.7 | 70.1 | 2026-09-14 23:59:51 |
|
||||
| 2026-09-15 | GB32960 | 50,349.1 | 1,367.4 | 2026-09-15 23:59:50 |
|
||||
|
||||
9 月 14 日累计差为 878.8 km,日里程为 70.1 km,差额为 808.7 km;9 月 15 日累计差与日里程则同为 1,367.4 km。该车适合对比正常日期与跨日缺报日期的计算路径。
|
||||
|
||||
## 5. 异常样本二:协议切换导致累计值倒退
|
||||
|
||||
车辆:沪A60591F。以下记录也已直接请求上游复核,均返回 `NORMAL`。
|
||||
|
||||
| 查询日期 | sourceProtocol | totalMileageKm | dailyMileageKm | dataTime(+08:00) |
|
||||
| --- | --- | ---: | ---: | --- |
|
||||
| 2026-09-12 | MQTT | 51,416 | 0 | 2026-09-12 04:53:49 |
|
||||
| 2026-09-13 | GB32960 | 42,075 | 0 | 2026-07-16 23:59:59 |
|
||||
| 2026-09-14 | GB32960 | 42,075 | 0 | 2026-07-16 23:59:59 |
|
||||
| 2026-09-15 | GB32960 | 42,075 | 0 | 2026-07-16 23:59:59 |
|
||||
|
||||
```text
|
||||
9 月 13 日累计差 = 42,075 - 51,416 = -9,341 km
|
||||
9 月 13 日日里程 = 0 km
|
||||
```
|
||||
|
||||
该车单独贡献 −9,341 km,叠加其他同协议车辆 +674.653 km 的差额后,形成当日全量净差额 −8,666.347 km。
|
||||
|
||||
请重点核查:
|
||||
|
||||
1. 同一请求优先级下,9 月 12 日采用 MQTT,9 月 13 日为何改用 7 月 16 日的 GB32960 读数?
|
||||
2. 协议“有效数据”的定义是否包含数据时间、新鲜度和查询日期边界?
|
||||
3. `NORMAL` 是否仅表示读数数值有效,而不表示查询当日存在有效报文?
|
||||
4. 两协议累计读数是否具有同一基准?切换时是否应提供不连续标识?
|
||||
5. 请核查该车协议记录对应的 VIN、设备及绑定历史,排除历史绑定变化造成的读数混用。
|
||||
|
||||
## 6. 上游直接复现方法
|
||||
|
||||
接口:`POST https://open.d.lnoneos.com/api/v1/vehicles/mileage/query`。
|
||||
|
||||
使用已授权的 API Key,通过环境变量注入,不将真实凭证写入报告或日志。
|
||||
|
||||
```bash
|
||||
curl --fail-with-body --silent --show-error \
|
||||
'https://open.d.lnoneos.com/api/v1/vehicles/mileage/query' \
|
||||
-H "Authorization: Bearer ${ONEOS_MILEAGE_API_KEY}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data '{
|
||||
"date": "2026-09-15",
|
||||
"plateNumbers": ["粤AGQ8377", "粤AGR6839", "粤AGP3609", "沪A60591F"],
|
||||
"protocolPriority": ["GB32960", "MQTT"]
|
||||
}'
|
||||
```
|
||||
|
||||
依次将 `date` 替换为 `2026-09-12`、`2026-09-13`、`2026-09-14`、`2026-09-15`。
|
||||
|
||||
逐车对比字段:`date`、`plateNumber`、`vin`、`status`、`dailyMileageKm`、`totalMileageKm`、`sourceProtocol`、`dataTime`、`calculatedAt`、`updatedAt`。
|
||||
|
||||
本次直接复核保留了报告中的关键字段,未记录响应 `traceId`;上游重放时请保存完整响应及 `traceId`。历史数据如已重算或补报,当前返回值可能与本报告不同,请同时提供重算时间和修改前后结果。
|
||||
|
||||
## 7. 请上游提供的定位结果
|
||||
|
||||
### 7.1 明确字段契约
|
||||
|
||||
- `dailyMileageKm`:是当天首尾读数差、前日末值到当日末值的差,还是逐报文有效增量之和?是否丢弃跨日增量、异常跳变或负增量?
|
||||
- `totalMileageKm`:是查询日最后读数、截至查询日最近读数,还是其他快照?无当日报文时是否延用历史值?
|
||||
- `dataTime`:具体对应累计读数、日里程终点还是其他时间?
|
||||
- 两个里程字段是否保证来自同一协议、同一设备、同一计算快照?
|
||||
- 自然日边界是否统一为 Asia/Shanghai 的 00:00:00 至次日 00:00:00?
|
||||
- 是否承诺相邻日累计差等于日里程;若不承诺,请明确哪些场景不成立及差额如何解释。
|
||||
|
||||
### 7.2 提供样本计算明细
|
||||
|
||||
对上述四辆车,请返回:
|
||||
|
||||
1. 各协议参与选源的候选记录、时间及未选中原因。
|
||||
2. 日里程计算起止报文的时间、累计读数、设备/VIN 标识。
|
||||
3. 有效增量、被过滤增量及过滤原因。
|
||||
4. 缺报、补报、跨日增量归属,以及历史重算记录。
|
||||
5. 对每个差额给出可核对的数值分解,不能只解释为“统计口径不同”。
|
||||
|
||||
## 8. 修复或口径确认后的验收建议
|
||||
|
||||
1. 使用同一车辆集合和协议配置,重放 9 月 11 至 15 日数据,逐车核对后再核对汇总。
|
||||
2. 对连续上报且同协议的正常车辆,若契约约定可对账,应满足累计差与日里程一致;容差按上游实际数值精度明确。
|
||||
3. 对跨日缺报车辆,明确跨日增量是否补算、归属哪天,并能解释全部差额。
|
||||
4. 对协议切换车辆,避免将不同基准的累计值视为连续可减的序列;提供明确的协议切换和不连续信息。
|
||||
5. 对陈旧读数,区分“历史累计值仍有效”和“当日有有效报文”,避免用单一 `NORMAL` 混淆两者。
|
||||
6. 验证单日接口与区间接口对同一车、同一天的日里程、累计值和选源结果一致。
|
||||
7. 如修复涉及历史重算,提供重算范围及完成时间;BI 历史接口缓存最长 6 小时,应在刷新后复核。
|
||||
|
||||
## 9. 本次排查边界
|
||||
|
||||
- 已检查 BI 里程映射、日/区间聚合和汇总逻辑,并对异常样本直接调用上游单日接口复核。
|
||||
- 尚未访问 OneOS 原始报文、计算服务源码或内部存储,不能确定其内部算法根因。
|
||||
- 本次未修改业务代码、未变更里程口径、未触发历史数据重算。
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
getWeeklyFlowRange,
|
||||
selectFlowDetails,
|
||||
type VehicleModalSelection,
|
||||
type FlowSelection,
|
||||
} from './model';
|
||||
import { SearchSelect } from '../../components/SearchSelect';
|
||||
import { MultiSearchSelect } from '../../components/MultiSearchSelect';
|
||||
@@ -87,7 +88,7 @@ export default function AssetsModule() {
|
||||
const [flowStats, setFlowStats] = useState<FlowStatsResponse | null>(null);
|
||||
const [flowLoading, setFlowLoading] = useState(false);
|
||||
const [flowDailyExpanded, setFlowDailyExpanded] = useState(false);
|
||||
const [selectedFlow, setSelectedFlow] = useState<{ date: string; type: FlowType } | null>(null);
|
||||
const [selectedFlow, setSelectedFlow] = useState<FlowSelection | null>(null);
|
||||
|
||||
// Dept/Region/Customer data
|
||||
const [deptData, setDeptData] = useState<DeptGroup[]>([]);
|
||||
@@ -384,11 +385,11 @@ export default function AssetsModule() {
|
||||
const source = rows ?? flowStats?.details ?? [];
|
||||
if (source.length === 0) return;
|
||||
const table = source.map((item) => ({
|
||||
日期: item.date,
|
||||
业务日期: item.date,
|
||||
类型: item.typeLabel,
|
||||
车牌: item.plateNumber,
|
||||
流转时间: item.eventTime || '',
|
||||
提交时间: item.submitTime || '',
|
||||
运维签章时间: item.eventTime || '',
|
||||
建单时间: item.submitTime || '',
|
||||
部门: item.department || '',
|
||||
业务负责人: item.manager || '',
|
||||
客户: item.customerName || '',
|
||||
|
||||
@@ -2,17 +2,18 @@ import React from 'react';
|
||||
import { Download, X } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import type { FlowDetailItem, FlowType } from '../api';
|
||||
import type { FlowSelection } from '../model';
|
||||
|
||||
export const FLOW_META: Record<FlowType, { label: string; tone: string; chip: string }> = {
|
||||
delivered: { label: '交车', tone: 'text-blue-600 bg-blue-50 border-blue-100', chip: 'bg-blue-50 text-blue-700 border-blue-100' },
|
||||
returned: { label: '还车', tone: 'text-orange-600 bg-orange-50 border-orange-100', chip: 'bg-orange-50 text-orange-700 border-orange-100' },
|
||||
replaced: { label: '替换', tone: 'text-violet-600 bg-violet-50 border-violet-100', chip: 'bg-violet-50 text-violet-700 border-violet-100' },
|
||||
replaced: { label: '替换交车', tone: 'text-violet-600 bg-violet-50 border-violet-100', chip: 'bg-violet-50 text-violet-700 border-violet-100' },
|
||||
};
|
||||
|
||||
interface FlowDetailModalProps {
|
||||
selectedFlow: { date: string; type: FlowType } | null;
|
||||
selectedFlow: FlowSelection | null;
|
||||
selectedFlowDetails: FlowDetailItem[];
|
||||
setSelectedFlow: React.Dispatch<React.SetStateAction<{ date: string; type: FlowType } | null>>;
|
||||
setSelectedFlow: React.Dispatch<React.SetStateAction<FlowSelection | null>>;
|
||||
exportFlowDetails: (rows?: FlowDetailItem[], title?: string) => void;
|
||||
}
|
||||
export function FlowDetailModal({
|
||||
@@ -21,6 +22,9 @@ export function FlowDetailModal({
|
||||
setSelectedFlow,
|
||||
exportFlowDetails,
|
||||
}: FlowDetailModalProps) {
|
||||
const dateLabel = selectedFlow
|
||||
? ('date' in selectedFlow ? selectedFlow.date : `${selectedFlow.start} 至 ${selectedFlow.end}`)
|
||||
: '';
|
||||
return (
|
||||
<>
|
||||
{/* Flow Detail Modal */}
|
||||
@@ -37,16 +41,17 @@ export function FlowDetailModal({
|
||||
<div className="border-b border-slate-100 bg-slate-950 px-4 py-4 text-white sm:px-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-black uppercase tracking-wide text-slate-400">提交时间口径</div>
|
||||
<div className="text-[11px] font-black uppercase tracking-wide text-slate-400">运维签章时间口径 · 单位:车次</div>
|
||||
<h3 className="mt-1 text-lg font-black">
|
||||
{selectedFlow.date} · {FLOW_META[selectedFlow.type].label}明细
|
||||
{dateLabel} · {FLOW_META[selectedFlow.type].label}明细
|
||||
</h3>
|
||||
<div className="mt-1 text-[12px] font-bold text-slate-300">
|
||||
共 {selectedFlowDetails.length} 条,包含车牌、流转时间、提交时间、部门、负责人和客户
|
||||
共 {selectedFlowDetails.length} 车次,包含车牌、运维签章时间、建单时间、部门、负责人和客户
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭流转明细"
|
||||
onClick={() => setSelectedFlow(null)}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
|
||||
>
|
||||
@@ -56,7 +61,7 @@ export function FlowDetailModal({
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => exportFlowDetails(selectedFlowDetails, `${selectedFlow.date}-${FLOW_META[selectedFlow.type].label}明细`)}
|
||||
onClick={() => exportFlowDetails(selectedFlowDetails, `${dateLabel}-${FLOW_META[selectedFlow.type].label}明细`)}
|
||||
disabled={selectedFlowDetails.length === 0}
|
||||
className="inline-flex h-8 items-center gap-1 rounded-xl bg-white px-3 text-[11px] font-black text-slate-900 transition hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
@@ -72,8 +77,8 @@ export function FlowDetailModal({
|
||||
<thead className="bg-slate-50 text-[11px] font-black text-slate-400">
|
||||
<tr>
|
||||
<th className="w-28 px-3 py-3">车牌</th>
|
||||
<th className="w-40 px-3 py-3">{FLOW_META[selectedFlow.type].label}时间</th>
|
||||
<th className="w-40 px-3 py-3">提交时间</th>
|
||||
<th className="w-40 px-3 py-3">运维签章时间</th>
|
||||
<th className="w-40 px-3 py-3">建单时间</th>
|
||||
<th className="w-36 px-3 py-3">部门</th>
|
||||
<th className="w-28 px-3 py-3">业务负责人</th>
|
||||
<th className="px-3 py-3">客户</th>
|
||||
@@ -82,7 +87,7 @@ export function FlowDetailModal({
|
||||
<tbody className="divide-y divide-slate-100 text-[12px] font-bold text-slate-700">
|
||||
{selectedFlowDetails.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-blue-50/40">
|
||||
<td className="px-3 py-3 font-black text-slate-950">{item.plateNumber}</td>
|
||||
<td className="px-3 py-3 font-black text-slate-950">{item.plateNumber}{item.type === 'replaced' && <span className="mt-1 block text-[10px] text-violet-600">替换交车</span>}</td>
|
||||
<td className="px-3 py-3 text-slate-500">{item.eventTime || '-'}</td>
|
||||
<td className="px-3 py-3 text-blue-600">{item.submitTime || '-'}</td>
|
||||
<td className="px-3 py-3">{item.department || '-'}</td>
|
||||
@@ -105,13 +110,13 @@ export function FlowDetailModal({
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-[10px] font-bold text-slate-400">
|
||||
<div>提交</div>
|
||||
<div>建单</div>
|
||||
<div className="mt-0.5 text-[11px] text-blue-600">{item.submitTime?.slice(5, 16) || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 text-[11px]">
|
||||
<div className="rounded-xl bg-slate-50 p-2">
|
||||
<div className="font-black text-slate-400">{item.typeLabel}时间</div>
|
||||
<div className="font-black text-slate-400">运维签章时间</div>
|
||||
<div className="mt-1 font-bold text-slate-700">{item.eventTime || '-'}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-slate-50 p-2">
|
||||
|
||||
@@ -66,6 +66,7 @@ export function VehicleDetailModal({
|
||||
showPlateNumbers.category === 'Delivered' ? '本周已交车' :
|
||||
showPlateNumbers.category === 'Returned' ? '已还车' :
|
||||
showPlateNumbers.category === 'Replaced' ? '已替换' :
|
||||
showPlateNumbers.category === 'Abnormal' ? '异动' :
|
||||
showPlateNumbers.category === 'Inventory' ? `${showPlateNumbers.location}库存` :
|
||||
showPlateNumbers.category === 'Operating' ? '正在运营' : '全部状态'}
|
||||
</p>
|
||||
|
||||
@@ -14,7 +14,7 @@ export function AssetSummaryDesktopTable({
|
||||
}: AssetSummarySectionProps) {
|
||||
return (
|
||||
<div className="hidden lg:block overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse table-fixed min-w-[1200px]">
|
||||
<table className="w-full text-left border-collapse table-fixed min-w-[1300px]">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 text-[11px] text-gray-500 uppercase tracking-wider border-b border-gray-100">
|
||||
<th className="p-3 font-semibold border-r border-gray-100 w-24">车型</th>
|
||||
@@ -26,6 +26,7 @@ export function AssetSummaryDesktopTable({
|
||||
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存-北京</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存-新疆</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存-其他</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 text-center text-amber-600 w-24">异动</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">待交车</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 text-center bg-green-50/30 w-24">当前在运营</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 text-center bg-blue-50/20 w-24">本周交车</th>
|
||||
@@ -43,7 +44,7 @@ export function AssetSummaryDesktopTable({
|
||||
'bg-blue-50/50 hover:bg-blue-50 transition-colors'
|
||||
}`}
|
||||
onClick={() => toggleAssetType(typeGroup.type)}>
|
||||
<td colSpan={14} className={`p-3 font-bold ${theme === 'vibrant' ? 'text-white' : 'text-blue-700'}`}>
|
||||
<td colSpan={15} className={`p-3 font-bold ${theme === 'vibrant' ? 'text-white' : 'text-blue-700'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{expandedAssetTypes.has(typeGroup.type) ?
|
||||
@@ -55,6 +56,7 @@ export function AssetSummaryDesktopTable({
|
||||
<div className={`flex gap-6 text-[11px] font-normal mr-4 ${theme === 'vibrant' ? 'text-white/80' : 'text-gray-500'}`}>
|
||||
<span>资产 <span className={theme === 'vibrant' ? 'font-bold text-white' : 'font-bold text-gray-700'}>{typeGroup.totalAssets}</span></span>
|
||||
<span>库存 <span className={theme === 'vibrant' ? 'font-bold text-white' : 'font-bold text-blue-600'}>{typeGroup.totalInventory}</span></span>
|
||||
<span>异动 <span className="font-bold">{typeGroup.totalAbnormal}</span></span>
|
||||
<span>运营 <span className={theme === 'vibrant' ? 'font-bold text-white' : 'font-bold text-green-600'}>{typeGroup.totalOperating}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -119,6 +121,12 @@ export function AssetSummaryDesktopTable({
|
||||
) : ''}
|
||||
</td>
|
||||
))}
|
||||
<td className="p-3 text-center border-r border-gray-100">
|
||||
<button type="button" className="font-bold text-amber-600 hover:underline"
|
||||
onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Abnormal', source: 'asset', title: `${model.model} - 异动` }); }}>
|
||||
{model.abnormal}
|
||||
</button>
|
||||
</td>
|
||||
<td className="p-3 text-center border-r border-gray-100">
|
||||
{model.pending > 0 ? (
|
||||
<button
|
||||
|
||||
@@ -33,6 +33,7 @@ export function AssetSummaryMobileCards({
|
||||
<div className={`flex gap-3 text-[9px] font-normal ${theme === 'vibrant' ? 'opacity-90' : 'text-gray-500'}`}>
|
||||
<span>资产 <span className={theme === 'vibrant' ? 'font-bold' : 'font-bold text-gray-700'}>{typeGroup.totalAssets}</span></span>
|
||||
<span>库存 <span className={theme === 'vibrant' ? 'font-bold' : 'font-bold text-blue-600'}>{typeGroup.totalInventory}</span></span>
|
||||
<span>异动 <span className="font-bold">{typeGroup.totalAbnormal}</span></span>
|
||||
<span>运营 <span className={theme === 'vibrant' ? 'font-bold' : 'font-bold text-green-600'}>{typeGroup.totalOperating}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -55,9 +56,10 @@ export function AssetSummaryMobileCards({
|
||||
{expandedModels.has(model.model) ? <ChevronDown size={14} className="text-blue-500" /> : <ChevronRight size={14} className="text-gray-300" />}
|
||||
<span className="text-xs font-bold text-gray-700">{model.model}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<span className="text-[10px] bg-blue-50 text-blue-600 px-1.5 py-0.5 rounded font-bold cursor-pointer active:bg-blue-100" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', source: 'asset', title: model.model }); }}>资产 {model.total}</span>
|
||||
<span className="text-[10px] bg-orange-50 text-orange-600 px-1.5 py-0.5 rounded font-bold cursor-pointer active:bg-orange-100" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Inventory', source: 'asset', title: `${model.model} - 库存` }); }}>库存 {model.inventory}</span>
|
||||
<button type="button" className="text-[10px] bg-amber-50 text-amber-700 px-1.5 py-0.5 rounded font-bold" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Abnormal', source: 'asset', title: `${model.model} - 异动` }); }}>异动 {model.abnormal}</button>
|
||||
<span className="text-[10px] bg-green-50 text-green-600 px-1.5 py-0.5 rounded font-bold cursor-pointer active:bg-green-100" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Operating', source: 'asset', title: `${model.model} - 在运营` }); }}>运营 {model.operating}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { ArrowRight, CalendarDays, Check, ChevronDown, ChevronLeft, ChevronRight, X } from 'lucide-react';
|
||||
import { getWeeklyFlowRange, type DateRange } from '../../model';
|
||||
|
||||
const weekdays = ['一', '二', '三', '四', '五', '六', '日'];
|
||||
const ymd = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
const parseDate = (value: string) => new Date(`${value}T12:00:00`);
|
||||
const shiftDay = (date: Date, offset: number) => new Date(date.getFullYear(), date.getMonth(), date.getDate() + offset);
|
||||
const dayCount = (start: string, end: string) => Math.round((Date.parse(`${end}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`)) / 86400000) + 1;
|
||||
|
||||
export function FlowDateRangePicker({ value, onChange }: { value: DateRange; onChange: (range: DateRange) => void }) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const [draft, setDraft] = useState(value);
|
||||
const [selecting, setSelecting] = useState<'start' | 'end'>('start');
|
||||
const [month, setMonth] = useState(() => parseDate(value.start));
|
||||
const [hovered, setHovered] = useState('');
|
||||
const today = ymd(new Date());
|
||||
const count = draft.end ? dayCount(draft.start, draft.end) : 0;
|
||||
const valid = count > 0 && count <= 370;
|
||||
const monthStart = new Date(month.getFullYear(), month.getMonth(), 1);
|
||||
const previewEnd = draft.end || hovered || draft.start;
|
||||
const rangeStart = draft.start < previewEnd ? draft.start : previewEnd;
|
||||
const rangeEnd = draft.start > previewEnd ? draft.start : previewEnd;
|
||||
const quickPicks = [
|
||||
{ label: '今天', range: { start: today, end: today } },
|
||||
{ label: '昨天', range: { start: ymd(shiftDay(new Date(), -1)), end: ymd(shiftDay(new Date(), -1)) } },
|
||||
{ label: '业务周', range: getWeeklyFlowRange() },
|
||||
{ label: '近 7 天', range: { start: ymd(shiftDay(new Date(), -6)), end: today } },
|
||||
{ label: '近 30 天', range: { start: ymd(shiftDay(new Date(), -29)), end: today } },
|
||||
];
|
||||
const open = () => {
|
||||
setDraft(value); setSelecting('start'); setMonth(parseDate(value.start)); setHovered('');
|
||||
dialog.current?.showModal();
|
||||
};
|
||||
const choose = (date: string) => {
|
||||
if (selecting === 'start') {
|
||||
setDraft({ start: date, end: '' }); setSelecting('end');
|
||||
} else {
|
||||
setDraft(date < draft.start ? { start: date, end: draft.start } : { start: draft.start, end: date });
|
||||
setSelecting('start');
|
||||
}
|
||||
setHovered('');
|
||||
};
|
||||
const calendar = (offset: number) => {
|
||||
const current = new Date(monthStart.getFullYear(), monthStart.getMonth() + offset, 1);
|
||||
const year = current.getFullYear(), m = current.getMonth();
|
||||
const padding = (current.getDay() + 6) % 7;
|
||||
const length = new Date(year, m + 1, 0).getDate();
|
||||
return <div className={offset ? 'hidden sm:block' : ''} key={offset}>
|
||||
<div className="mb-3 text-center text-sm font-bold text-slate-700">{year} 年 {m + 1} 月</div>
|
||||
<div className="grid grid-cols-7 text-center">
|
||||
{weekdays.map(day => <div key={day} className="py-2 text-[11px] font-medium text-slate-400">{day}</div>)}
|
||||
{Array.from({ length: padding }, (_, i) => <div key={`blank-${i}`} />)}
|
||||
{Array.from({ length }, (_, i) => {
|
||||
const date = ymd(new Date(year, m, i + 1));
|
||||
const endpoint = date === draft.start || date === draft.end;
|
||||
const inRange = date >= rangeStart && date <= rangeEnd;
|
||||
return <button key={date} type="button" aria-label={date} aria-pressed={endpoint}
|
||||
onClick={() => choose(date)} onMouseEnter={() => { if (!draft.end) setHovered(date); }}
|
||||
className={`relative my-0.5 h-10 text-xs font-semibold transition focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-blue-500 ${endpoint ? 'rounded-xl bg-blue-600 text-white shadow-sm' : inRange ? 'bg-blue-50 text-blue-700' : 'rounded-xl text-slate-600 hover:bg-slate-100'} ${date === today && !endpoint ? 'font-black text-blue-600' : ''}`}>
|
||||
{i + 1}{date === today && <span className={`absolute bottom-1 left-1/2 h-1 w-1 -translate-x-1/2 rounded-full ${endpoint ? 'bg-white' : 'bg-blue-500'}`} />}
|
||||
</button>;
|
||||
})}
|
||||
</div>
|
||||
</div>;
|
||||
};
|
||||
return <>
|
||||
<button type="button" data-testid="asset-flow-date-range" aria-haspopup="dialog" onClick={open}
|
||||
className="mt-3 flex w-full items-center gap-3 rounded-2xl border border-slate-200 bg-white px-4 py-3 text-left shadow-sm transition hover:border-blue-300 hover:bg-blue-50/40 focus-visible:outline-2 focus-visible:outline-blue-500">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-blue-50 text-blue-600"><CalendarDays size={18} /></span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-[10px] font-medium text-slate-400">统计日期 · 运维签章时间</span>
|
||||
<span className="mt-1 flex items-center gap-2 text-[13px] font-bold text-slate-700"><span>{value.start.replaceAll('-', '/')}</span><ArrowRight size={13} className="shrink-0 text-slate-300" /><span>{value.end.replaceAll('-', '/')}</span></span>
|
||||
</span>
|
||||
<span className="hidden rounded-lg bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-500 sm:block">{dayCount(value.start, value.end)} 天</span>
|
||||
<ChevronDown size={16} className="shrink-0 text-slate-400" />
|
||||
</button>
|
||||
<dialog ref={dialog} aria-labelledby="flow-date-title" onClick={e => { if (e.target === e.currentTarget) dialog.current?.close(); }}
|
||||
className="fixed inset-0 m-auto max-h-[90dvh] w-[calc(100%-24px)] max-w-[680px] overflow-y-auto rounded-3xl border-0 bg-white p-0 text-slate-700 shadow-2xl backdrop:bg-slate-950/30 backdrop:backdrop-blur-sm">
|
||||
<div className="p-5 sm:p-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div><h3 id="flow-date-title" className="text-base font-bold text-slate-900">选择统计日期</h3><p className="mt-1 text-xs text-slate-400">先选开始日期,再选结束日期</p></div>
|
||||
<button type="button" aria-label="关闭日期选择" onClick={() => dialog.current?.close()} className="rounded-full p-2 text-slate-400 hover:bg-slate-100"><X size={18} /></button>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{quickPicks.map(pick => <button key={pick.label} type="button" onClick={() => { setDraft(pick.range); setMonth(parseDate(pick.range.start)); setSelecting('start'); setHovered(''); }}
|
||||
className={`rounded-lg border px-3 py-2 text-xs font-medium transition ${draft.start === pick.range.start && draft.end === pick.range.end ? 'border-blue-200 bg-blue-50 text-blue-600' : 'border-slate-100 text-slate-500 hover:border-blue-200 hover:bg-blue-50'}`}>{pick.label}</button>)}
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||
{(['start', 'end'] as const).map(field => <button key={field} type="button" onClick={() => { setSelecting(field); setHovered(''); setMonth(parseDate(draft[field] || draft.start)); }}
|
||||
className={`rounded-xl border px-3 py-2.5 text-left transition ${selecting === field ? 'border-blue-400 bg-blue-50/60 ring-2 ring-blue-50' : 'border-slate-200 bg-slate-50/50'}`}>
|
||||
<span className="block text-[10px] text-slate-400">{field === 'start' ? '开始日期' : '结束日期'}</span><span className="mt-1 block text-sm font-bold">{draft[field] || '请选择结束日期'}</span>
|
||||
</button>)}
|
||||
</div>
|
||||
<div className="mt-5 flex items-center justify-between gap-2">
|
||||
<button type="button" aria-label="上个月" onClick={() => setMonth(new Date(monthStart.getFullYear(), monthStart.getMonth() - 1, 1))} className="rounded-lg p-2 hover:bg-slate-100"><ChevronLeft size={18} /></button>
|
||||
<div className="flex gap-2">
|
||||
<select aria-label="年份" value={monthStart.getFullYear()} onChange={e => setMonth(new Date(Number(e.target.value), monthStart.getMonth(), 1))} className="rounded-lg bg-slate-50 px-2 py-1.5 text-xs font-semibold">
|
||||
{Array.from({ length: Math.max(new Date().getFullYear() + 1, monthStart.getFullYear()) - Math.min(2016, monthStart.getFullYear()) + 1 }, (_, i) => Math.min(2016, monthStart.getFullYear()) + i).map(year => <option key={year} value={year}>{year} 年</option>)}
|
||||
</select>
|
||||
<select aria-label="月份" value={monthStart.getMonth()} onChange={e => setMonth(new Date(monthStart.getFullYear(), Number(e.target.value), 1))} className="rounded-lg bg-slate-50 px-2 py-1.5 text-xs font-semibold">
|
||||
{Array.from({ length: 12 }, (_, i) => <option key={i} value={i}>{i + 1} 月</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" aria-label="下个月" onClick={() => setMonth(new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1))} className="rounded-lg p-2 hover:bg-slate-100"><ChevronRight size={18} /></button>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-6 sm:grid-cols-2" onMouseLeave={() => setHovered('')}>{calendar(0)}{calendar(1)}</div>
|
||||
</div>
|
||||
<div className="sticky bottom-0 flex items-center justify-between gap-3 border-t border-slate-100 bg-white px-5 py-4 sm:px-6">
|
||||
<span role="status" className={`text-xs ${count > 370 ? 'text-red-500' : 'text-slate-400'}`}>{!draft.end ? '请选择结束日期' : count > 370 ? '最多选择 370 天' : `已选 ${count} 天(包含首尾)`}</span>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<button type="button" onClick={() => dialog.current?.close()} className="rounded-xl px-3 py-2.5 text-xs font-semibold text-slate-500 hover:bg-slate-100">取消</button>
|
||||
<button type="button" disabled={!valid} onClick={() => { onChange(draft); dialog.current?.close(); }} className="flex items-center gap-1 rounded-xl bg-blue-600 px-4 py-2.5 text-xs font-semibold text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-40"><Check size={14} />应用日期</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</>;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { AnimatePresence, motion } from 'motion/react';
|
||||
import type { FlowType } from '../../api';
|
||||
import { FLOW_META } from '../FlowDetailModal';
|
||||
import type { FlowStatisticsCardProps } from './types';
|
||||
import { FlowDateRangePicker } from './FlowDateRangePicker';
|
||||
|
||||
const FLOW_TYPES: FlowType[] = ['delivered', 'returned', 'replaced'];
|
||||
|
||||
@@ -23,7 +24,7 @@ export function FlowStatisticsCard({
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 text-[13px] font-black text-slate-700">
|
||||
<CalendarDays size={14} className="text-blue-500" />
|
||||
<span>交还车统计</span>
|
||||
<span>交还车统计(车次)</span>
|
||||
{flowLoading && <Loader2 size={12} className="animate-spin text-slate-400" />}
|
||||
</div>
|
||||
</div>
|
||||
@@ -39,37 +40,8 @@ export function FlowStatisticsCard({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 rounded-xl border border-slate-100 bg-slate-50/80 px-2 py-1.5">
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2">
|
||||
<label className="relative cursor-pointer rounded-lg px-2 py-1 transition hover:bg-white">
|
||||
<span className="block text-[9px] font-black text-slate-400">开始</span>
|
||||
<span className="mt-0.5 flex items-center justify-between gap-2">
|
||||
<span className="text-[12px] font-black text-slate-700">{flowRange.start.replaceAll('-', '/')}</span>
|
||||
<CalendarDays size={12} className="text-slate-300" />
|
||||
</span>
|
||||
<input
|
||||
type="date"
|
||||
value={flowRange.start}
|
||||
onChange={(e) => setFlowRange((prev) => ({ ...prev, start: e.target.value }))}
|
||||
className="asset-date-input absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
||||
/>
|
||||
</label>
|
||||
<div className="rounded-full bg-slate-200/70 px-2 py-0.5 text-[9px] font-black text-slate-400">至</div>
|
||||
<label className="relative cursor-pointer rounded-lg px-2 py-1 transition hover:bg-white">
|
||||
<span className="block text-[9px] font-black text-slate-400">结束</span>
|
||||
<span className="mt-0.5 flex items-center justify-between gap-2">
|
||||
<span className="text-[12px] font-black text-slate-700">{flowRange.end.replaceAll('-', '/')}</span>
|
||||
<CalendarDays size={12} className="text-slate-300" />
|
||||
</span>
|
||||
<input
|
||||
type="date"
|
||||
value={flowRange.end}
|
||||
onChange={(e) => setFlowRange((prev) => ({ ...prev, end: e.target.value }))}
|
||||
className="asset-date-input absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-slate-400">按运维签章时间统计;交车包含替换交车,合计=交车+还车,同车多笔业务分别计次。</p>
|
||||
<FlowDateRangePicker value={flowRange} onChange={setFlowRange} />
|
||||
<div className="mt-2 rounded-2xl border border-slate-100 bg-slate-50/70 px-2 py-2.5">
|
||||
<div className="grid grid-cols-4 items-center text-center">
|
||||
<div className="px-2">
|
||||
@@ -77,12 +49,16 @@ export function FlowStatisticsCard({
|
||||
<div className="mt-1 text-[10px] font-black text-slate-400">合计</div>
|
||||
</div>
|
||||
{FLOW_TYPES.map((type) => (
|
||||
<div key={type} className="border-l border-slate-200/70 px-2">
|
||||
<button key={type} type="button"
|
||||
data-testid={`asset-flow-total-${type}`}
|
||||
disabled={flowLoading || !flowStats?.totals[type]}
|
||||
onClick={() => flowStats && setSelectedFlow({ start: flowStats.start, end: flowStats.end, type })}
|
||||
className="border-l border-slate-200/70 px-2 transition hover:bg-slate-100 focus-visible:outline-blue-500 disabled:cursor-not-allowed disabled:opacity-35">
|
||||
<div className={`text-lg font-black leading-none ${type === 'delivered' ? 'text-blue-600' : type === 'returned' ? 'text-orange-600' : 'text-violet-600'}`}>
|
||||
{flowStats?.totals[type] ?? 0}
|
||||
</div>
|
||||
<div className={`mt-1 text-[10px] font-black ${type === 'delivered' ? 'text-blue-500' : type === 'returned' ? 'text-orange-500' : 'text-violet-500'}`}>{FLOW_META[type].label}</div>
|
||||
</div>
|
||||
<div className={`mt-1 text-[10px] font-black ${type === 'delivered' ? 'text-blue-500' : type === 'returned' ? 'text-orange-500' : 'text-violet-500'}`}>{type === 'replaced' ? '其中:替换交车' : FLOW_META[type].label}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -114,7 +90,7 @@ export function FlowStatisticsCard({
|
||||
<div key={day.date} className="rounded-2xl border border-slate-100 bg-white px-3 py-3 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-[11px] font-black text-slate-400">{day.date}</div>
|
||||
<div className="text-[10px] font-bold italic text-slate-300">提交时间</div>
|
||||
<div className="text-[10px] font-bold italic text-slate-300">运维签章时间</div>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-4 items-center text-center">
|
||||
<div className="px-2">
|
||||
@@ -133,7 +109,7 @@ export function FlowStatisticsCard({
|
||||
className="border-l border-slate-100 px-2 text-center transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-35"
|
||||
>
|
||||
<div className={`text-lg font-black leading-none ${type === 'delivered' ? 'text-blue-600' : type === 'returned' ? 'text-orange-600' : 'text-violet-600'}`}>{count}</div>
|
||||
<div className={`mt-1 text-[10px] font-black ${type === 'delivered' ? 'text-blue-500' : type === 'returned' ? 'text-orange-500' : 'text-violet-500'}`}>{FLOW_META[type].label}</div>
|
||||
<div className={`mt-1 text-[10px] font-black ${type === 'delivered' ? 'text-blue-500' : type === 'returned' ? 'text-orange-500' : 'text-violet-500'}`}>{type === 'replaced' ? '其中:替换交车' : FLOW_META[type].label}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Activity, PlusCircle, Truck, Warehouse } from 'lucide-react';
|
||||
import { Activity, TriangleAlert, PlusCircle, Truck, Warehouse } from 'lucide-react';
|
||||
import type { SummaryMetricsProps } from './types';
|
||||
|
||||
export function SummaryMetrics({
|
||||
@@ -11,7 +11,7 @@ export function SummaryMetrics({
|
||||
return (
|
||||
<>
|
||||
{/* Header Summary - Ultra Compact */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 mb-2">
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2 mb-2">
|
||||
{/* Total Assets */}
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', source: 'asset', title: '资产概览' })}>
|
||||
@@ -49,11 +49,21 @@ export function SummaryMetrics({
|
||||
<div className="text-[9px] text-gray-400 font-medium uppercase leading-none mb-0.5">总库存</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-base font-bold text-gray-800 leading-none">{SUMMARY.inventory.total}</span>
|
||||
<span className="text-[8px] text-gray-400 leading-none">库{SUMMARY.inventory.inStock} 异{SUMMARY.inventory.abnormal}</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" data-testid="asset-abnormal-card"
|
||||
className="rounded-2xl border border-amber-100 bg-white p-3 shadow-sm flex items-center gap-2 text-left transition-all hover:-translate-y-0.5 hover:shadow-md"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Abnormal', source: 'asset', title: '异动车辆' })}>
|
||||
<div className="w-8 h-8 rounded-xl bg-amber-50 flex items-center justify-center text-amber-600"><TriangleAlert size={14} /></div>
|
||||
<div>
|
||||
<div className="text-[9px] text-amber-600 font-bold leading-none mb-0.5">异动</div>
|
||||
<div className="text-base font-bold text-amber-600 leading-none">{SUMMARY.inventory.abnormal}</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Pending */}
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Pending', source: 'asset', title: '待交车' })}>
|
||||
@@ -70,7 +80,7 @@ export function SummaryMetrics({
|
||||
<div data-testid="asset-operation-ratio-strip" className="mb-3 rounded-2xl border border-slate-100 bg-white/85 px-4 py-3 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="shrink-0 text-[11px] font-black text-slate-400">运营概览</div>
|
||||
<div className="grid min-w-0 flex-1 grid-cols-3 divide-x divide-slate-100 text-center">
|
||||
<div className="grid min-w-0 flex-1 grid-cols-4 divide-x divide-slate-100 text-center">
|
||||
<div className="px-2">
|
||||
<div className="text-[10px] font-black text-slate-400">运营率</div>
|
||||
<div className="mt-0.5 text-base font-black text-blue-600">{operatingRate.toFixed(1)}%</div>
|
||||
@@ -79,6 +89,10 @@ export function SummaryMetrics({
|
||||
<div className="text-[10px] font-black text-slate-400">库存率</div>
|
||||
<div className="mt-0.5 text-base font-black text-slate-700">{inventoryRate.toFixed(1)}%</div>
|
||||
</div>
|
||||
<div className="px-2">
|
||||
<div className="text-[10px] font-black text-slate-400">异动率</div>
|
||||
<div className="mt-0.5 text-base font-black text-amber-600">{(SUMMARY.totalAssets > 0 ? SUMMARY.inventory.abnormal / SUMMARY.totalAssets * 100 : 0).toFixed(1)}%</div>
|
||||
</div>
|
||||
<div className="px-2">
|
||||
<div className="text-[10px] font-black text-slate-400">待交率</div>
|
||||
<div className="mt-0.5 text-base font-black text-amber-600">{pendingRate.toFixed(1)}%</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Dispatch, SetStateAction } from 'react';
|
||||
import type { FlowDetailItem, FlowStatsResponse, FlowType } from '../../api';
|
||||
import type {
|
||||
DateRange,
|
||||
FlowSelection,
|
||||
InventoryFilters,
|
||||
VehicleModalSelection,
|
||||
} from '../../model';
|
||||
@@ -24,7 +25,7 @@ export interface FlowStatisticsCardProps {
|
||||
exportFlowDetails: (rows?: FlowDetailItem[], title?: string) => void;
|
||||
flowDailyExpanded: boolean;
|
||||
setFlowDailyExpanded: Dispatch<SetStateAction<boolean>>;
|
||||
setSelectedFlow: Dispatch<SetStateAction<{ date: string; type: FlowType } | null>>;
|
||||
setSelectedFlow: Dispatch<SetStateAction<FlowSelection | null>>;
|
||||
}
|
||||
|
||||
export interface AssetSummarySectionProps {
|
||||
|
||||
@@ -296,6 +296,20 @@ test('流转明细按日期和类型同时筛选,客户饼图按区域汇总',
|
||||
['1'],
|
||||
);
|
||||
|
||||
flowStats.details.push(
|
||||
{ ...flowStats.details[0], id: '3', type: 'replaced', typeLabel: '替换交车' },
|
||||
{ ...flowStats.details[0], id: '4', type: 'replaced', typeLabel: '替换交车', date: '2026-08-13' },
|
||||
);
|
||||
assert.deepEqual(selectFlowDetails(flowStats, { date: '2026-08-12', type: 'delivered' }).map(x => x.id), ['1', '3']);
|
||||
assert.deepEqual(selectFlowDetails(flowStats, { date: '2026-08-12', type: 'replaced' }).map(x => x.id), ['3']);
|
||||
assert.deepEqual(selectFlowDetails(flowStats, { date: '2026-08-12', type: 'returned' }).map(x => x.id), ['2']);
|
||||
|
||||
assert.deepEqual(selectFlowDetails(flowStats, { start: '2026-08-12', end: '2026-08-13', type: 'delivered' }).map(x => x.id), ['1', '3', '4']);
|
||||
assert.deepEqual(selectFlowDetails(flowStats, { start: '2026-08-12', end: '2026-08-13', type: 'returned' }).map(x => x.id), ['2']);
|
||||
assert.deepEqual(selectFlowDetails(flowStats, { start: '2026-08-12', end: '2026-08-13', type: 'replaced' }).map(x => x.id), ['3', '4']);
|
||||
assert.deepEqual(selectFlowDetails(flowStats, { start: '2026-08-13', end: '2026-08-13', type: 'delivered' }).map(x => x.id), ['4']);
|
||||
assert.deepEqual(selectFlowDetails(flowStats, { start: '2026-08-14', end: '2026-08-15', type: 'delivered' }), []);
|
||||
|
||||
const customers = [
|
||||
customer({ region: '广东', total: 2 }),
|
||||
customer({ customer: '客户乙', region: '浙江', total: 5 }),
|
||||
|
||||
@@ -50,6 +50,7 @@ export interface ModalVehicleFilters {
|
||||
|
||||
export type VehicleModalCategory =
|
||||
| 'Inventory'
|
||||
| 'Abnormal'
|
||||
| 'Pending'
|
||||
| 'Delivered'
|
||||
| 'Returned'
|
||||
@@ -77,7 +78,7 @@ export interface VehicleListRequestParams {
|
||||
batch?: string;
|
||||
model?: string;
|
||||
location?: string;
|
||||
category?: 'Inventory' | 'Operating' | 'Pending';
|
||||
category?: 'Inventory' | 'Abnormal' | 'Operating' | 'Pending';
|
||||
vehicleType?: string;
|
||||
manager?: string;
|
||||
customer?: string;
|
||||
@@ -97,10 +98,9 @@ export type VehicleModalRequest =
|
||||
}
|
||||
| { kind: 'vehicles'; params: VehicleListRequestParams };
|
||||
|
||||
export interface FlowSelection {
|
||||
date: string;
|
||||
type: FlowType;
|
||||
}
|
||||
export type FlowSelection =
|
||||
| { date: string; type: FlowType }
|
||||
| { start: string; end: string; type: FlowType };
|
||||
|
||||
export function formatLocalDateTime(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
@@ -172,6 +172,7 @@ export function buildVehicleModalRequest(
|
||||
if (selection.model !== 'All') params.model = selection.model;
|
||||
if (selection.location !== 'All') params.location = selection.location;
|
||||
if (selection.source) params.source = selection.source;
|
||||
if (selection.category === 'Abnormal') params.category = 'Abnormal';
|
||||
if (selection.category === 'Inventory') params.category = 'Inventory';
|
||||
if (selection.category === 'Operating') params.category = 'Operating';
|
||||
if (selection.category === 'Pending') params.category = 'Pending';
|
||||
@@ -391,7 +392,10 @@ export function selectFlowDetails(
|
||||
) {
|
||||
if (!flowStats || !selection) return [];
|
||||
return flowStats.details.filter((detail) => (
|
||||
detail.date === selection.date && detail.type === selection.type
|
||||
('date' in selection
|
||||
? detail.date === selection.date
|
||||
: detail.date >= selection.start && detail.date <= selection.end) && (detail.type === selection.type ||
|
||||
(selection.type === 'delivered' && detail.type === 'replaced'))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface BatchSummary {
|
||||
batch: string;
|
||||
total: number;
|
||||
inventory: number;
|
||||
abnormal: number;
|
||||
inventoryRegions: Record<string, number>;
|
||||
pending: number;
|
||||
operating: number;
|
||||
@@ -36,6 +37,7 @@ export interface ModelSummary {
|
||||
model: string;
|
||||
total: number;
|
||||
inventory: number;
|
||||
abnormal: number;
|
||||
inventoryRegions: Record<string, number>;
|
||||
pending: number;
|
||||
operating: number;
|
||||
@@ -49,6 +51,7 @@ export interface TypeSummary {
|
||||
type: string;
|
||||
totalAssets: number;
|
||||
totalInventory: number;
|
||||
totalAbnormal: number;
|
||||
totalOperating: number;
|
||||
inventoryRegions: Record<string, number>;
|
||||
pending: number;
|
||||
@@ -62,6 +65,7 @@ export interface BatchGroup {
|
||||
batch: string;
|
||||
total: number;
|
||||
inventory: number;
|
||||
abnormal: number;
|
||||
inventoryRegions: Record<string, number>;
|
||||
pending: number;
|
||||
operating: number;
|
||||
@@ -73,6 +77,7 @@ export interface BatchGroup {
|
||||
type: string;
|
||||
total: number;
|
||||
inventory: number;
|
||||
abnormal: number;
|
||||
inventoryRegions: Record<string, number>;
|
||||
pending: number;
|
||||
operating: number;
|
||||
@@ -153,6 +158,7 @@ export interface RegionTypeBreakdown {
|
||||
total: number;
|
||||
operating: number;
|
||||
inventory: number;
|
||||
abnormal: number;
|
||||
pending: number;
|
||||
customers: string[];
|
||||
}
|
||||
|
||||
@@ -48,14 +48,14 @@ export function registerHydrogenDailyRoute(
|
||||
const [stationRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS d,
|
||||
COALESCE(b.station_id, 0) AS stationId,
|
||||
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name),
|
||||
COALESCE(MAX(NULLIF(s.site_short_name, '')), MAX(NULLIF(s.site_name, '')), MAX(NULLIF(b.station_name, '')),
|
||||
CASE WHEN MAX(b.station_id) IS NULL THEN '未关联站点'
|
||||
ELSE CONCAT('未知站点 #', MAX(b.station_id)) END) AS stationName,
|
||||
ROUND(SUM(b.amount_kg), 2) AS kg,
|
||||
-- 单价:直接取订单中的成本价(不重算)。MAX 自然忽略 0 元的免费/赠送单
|
||||
MAX(b.cost_price) AS pricePerKg
|
||||
FROM ${HYDROGEN_TABLE} b
|
||||
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
|
||||
LEFT JOIN new_hydrogen_site s ON s.id = b.station_id AND s.del_flag = '0'
|
||||
WHERE ${where}
|
||||
GROUP BY d, COALESCE(b.station_id, 0)
|
||||
ORDER BY d DESC, kg DESC`,
|
||||
|
||||
@@ -119,13 +119,13 @@ export function registerHydrogenOverviewRoute(
|
||||
// Top5 加氢站(指定年份)
|
||||
const [top5Rows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT b.station_id AS id,
|
||||
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name),
|
||||
COALESCE(MAX(NULLIF(s.site_short_name, '')), MAX(NULLIF(s.site_name, '')), MAX(NULLIF(b.station_name, '')),
|
||||
CASE WHEN b.station_id IS NULL THEN '未关联站点'
|
||||
ELSE CONCAT('未知站点 #', b.station_id) END) AS name,
|
||||
SUM(b.amount_kg) AS kg,
|
||||
SUM(b.cost_total) AS fee
|
||||
FROM ${HYDROGEN_TABLE} b
|
||||
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
|
||||
LEFT JOIN new_hydrogen_site s ON s.id = b.station_id AND s.del_flag = '0'
|
||||
WHERE ${HYDROGEN_BASE_WHERE_B}
|
||||
AND b.${HYDROGEN_LOCAL} >= ?
|
||||
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
|
||||
@@ -146,13 +146,13 @@ export function registerHydrogenOverviewRoute(
|
||||
// 加氢站全量汇总(同年所有站,按加氢量降序)
|
||||
const [stationFullRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT b.station_id AS id,
|
||||
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name),
|
||||
COALESCE(MAX(NULLIF(s.site_short_name, '')), MAX(NULLIF(s.site_name, '')), MAX(NULLIF(b.station_name, '')),
|
||||
CASE WHEN b.station_id IS NULL THEN '未关联站点'
|
||||
ELSE CONCAT('未知站点 #', b.station_id) END) AS name,
|
||||
SUM(b.amount_kg) AS kg,
|
||||
SUM(b.fee_total) AS revenue
|
||||
FROM ${HYDROGEN_TABLE} b
|
||||
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
|
||||
LEFT JOIN new_hydrogen_site s ON s.id = b.station_id AND s.del_flag = '0'
|
||||
WHERE ${HYDROGEN_BASE_WHERE_B}
|
||||
AND b.${HYDROGEN_LOCAL} >= ?
|
||||
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
|
||||
@@ -174,18 +174,18 @@ export function registerHydrogenOverviewRoute(
|
||||
const [regionRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT region, SUM(kg) AS kg FROM (
|
||||
SELECT CASE
|
||||
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%嘉兴%' OR COALESCE(s.station_name, b.station_name, '') LIKE '%平湖%' THEN '嘉兴'
|
||||
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%广州%' THEN '广州'
|
||||
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%佛山%' THEN '佛山'
|
||||
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%成都%' THEN '成都'
|
||||
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%重庆%' THEN '重庆'
|
||||
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%乌鲁木齐%' THEN '乌鲁木齐'
|
||||
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%昆山%' THEN '昆山'
|
||||
ELSE COALESCE(NULLIF(s.station_name, ''), NULLIF(b.station_name, ''), '未知')
|
||||
WHEN COALESCE(NULLIF(s.site_name, ''), NULLIF(b.station_name, ''), '') LIKE '%嘉兴%' OR COALESCE(NULLIF(s.site_name, ''), NULLIF(b.station_name, ''), '') LIKE '%平湖%' THEN '嘉兴'
|
||||
WHEN COALESCE(NULLIF(s.site_name, ''), NULLIF(b.station_name, ''), '') LIKE '%广州%' THEN '广州'
|
||||
WHEN COALESCE(NULLIF(s.site_name, ''), NULLIF(b.station_name, ''), '') LIKE '%佛山%' THEN '佛山'
|
||||
WHEN COALESCE(NULLIF(s.site_name, ''), NULLIF(b.station_name, ''), '') LIKE '%成都%' THEN '成都'
|
||||
WHEN COALESCE(NULLIF(s.site_name, ''), NULLIF(b.station_name, ''), '') LIKE '%重庆%' THEN '重庆'
|
||||
WHEN COALESCE(NULLIF(s.site_name, ''), NULLIF(b.station_name, ''), '') LIKE '%乌鲁木齐%' THEN '乌鲁木齐'
|
||||
WHEN COALESCE(NULLIF(s.site_name, ''), NULLIF(b.station_name, ''), '') LIKE '%昆山%' THEN '昆山'
|
||||
ELSE COALESCE(NULLIF(s.site_name, ''), NULLIF(b.station_name, ''), '未知')
|
||||
END AS region,
|
||||
b.amount_kg AS kg
|
||||
FROM ${HYDROGEN_TABLE} b
|
||||
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
|
||||
LEFT JOIN new_hydrogen_site s ON s.id = b.station_id AND s.del_flag = '0'
|
||||
WHERE ${HYDROGEN_BASE_WHERE_B}
|
||||
AND b.${HYDROGEN_LOCAL} >= ?
|
||||
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
|
||||
|
||||
@@ -61,7 +61,7 @@ test('能源路由继续按原顺序注册四个 GET 接口', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('四个处理器继续使用原缓存语义、数据库、SQL 与参数', async () => {
|
||||
test('四个处理器保持缓存和参数契约,加氢站点使用新主数据表', async () => {
|
||||
const hydrogenCalls: QueryCall[] = [];
|
||||
const electricCalls: QueryCall[] = [];
|
||||
const cacheCalls: CacheCall[] = [];
|
||||
@@ -133,12 +133,12 @@ test('四个处理器继续使用原缓存语义、数据库、SQL 与参数', a
|
||||
[
|
||||
'95f6d99b61c9',
|
||||
'63a63c5f78bb',
|
||||
'1367eddb3b7f',
|
||||
'83a52a7f9336',
|
||||
'f28fa4a3cb08',
|
||||
'9cf6719a39c3',
|
||||
'089d8a10d666',
|
||||
'256ac8309d5a',
|
||||
'dac9e86fc527',
|
||||
'a71da8f05765',
|
||||
'b562668859ce',
|
||||
'24b23780a259',
|
||||
'226fd8f950a1',
|
||||
'6e4031b10926',
|
||||
'4fc77463294c',
|
||||
|
||||
@@ -70,7 +70,7 @@ app.get('/summary', async (c) => {
|
||||
hanging: 0,
|
||||
},
|
||||
inventory: {
|
||||
total: vehicles.filter((v) => v.status === 'Inventory' || v.status === 'Abnormal').length,
|
||||
total: vehicles.filter((v) => v.status === 'Inventory').length,
|
||||
inStock: vehicles.filter((v) => v.status === 'Inventory').length,
|
||||
abnormal: vehicles.filter((v) => v.status === 'Abnormal').length,
|
||||
},
|
||||
@@ -111,6 +111,7 @@ app.get('/by-type', async (c) => {
|
||||
type: t.name,
|
||||
totalAssets: typeVehicles.length,
|
||||
totalInventory: typeStats.inventory,
|
||||
totalAbnormal: typeStats.abnormal,
|
||||
totalOperating: typeStats.operating,
|
||||
inventoryRegions: typeStats.inventoryRegions,
|
||||
pending: typeStats.pending,
|
||||
@@ -273,6 +274,7 @@ app.get('/region-stats', async (c) => {
|
||||
total: tv.length,
|
||||
operating: tv.filter((v) => v.status === 'Operating').length,
|
||||
inventory: tv.filter((v) => v.status === 'Inventory').length,
|
||||
abnormal: tv.filter((v) => v.status === 'Abnormal').length,
|
||||
pending: tv.filter((v) => v.status === 'Pending').length,
|
||||
customers: Array.from(new Set(tv.map((v) => v.customerName).filter(Boolean))) as string[],
|
||||
});
|
||||
@@ -405,7 +407,9 @@ app.get('/list', async (c) => {
|
||||
}
|
||||
if (category) {
|
||||
if (category === 'Inventory') {
|
||||
filtered = filtered.filter((v) => v.status === 'Inventory' || v.status === 'Abnormal');
|
||||
filtered = filtered.filter((v) => v.status === 'Inventory');
|
||||
} else if (category === 'Abnormal') {
|
||||
filtered = filtered.filter((v) => v.status === 'Abnormal');
|
||||
} else if (category === 'Operating') {
|
||||
filtered = filtered.filter((v) => v.status === 'Operating');
|
||||
} else if (category === 'Pending') {
|
||||
@@ -461,7 +465,7 @@ app.get('/list', async (c) => {
|
||||
// GET /api/vehicles/inventory-stats — 库存统计,不设数据权限,对所有人开放
|
||||
app.get('/inventory-stats', async (c) => {
|
||||
const vehicles = applySubjectFilter(c, await vehicleRepository.getVehicles());
|
||||
const inventory = vehicles.filter((v) => v.status === 'Inventory' || v.status === 'Abnormal');
|
||||
const inventory = vehicles.filter((v) => v.status === 'Inventory');
|
||||
|
||||
const TYPE_NAME_MAP: Record<string, string> = {
|
||||
t4_5: '4.5T普货',
|
||||
@@ -525,7 +529,7 @@ app.get('/weekly-detail', async (c) => {
|
||||
});
|
||||
|
||||
// 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);
|
||||
@@ -555,6 +559,7 @@ app.get('/flow-stats', async (c) => {
|
||||
const stat = dailyMap.get(item.date);
|
||||
if (!stat) continue;
|
||||
stat[item.type] += 1;
|
||||
if (item.type === 'replaced') stat.delivered += 1;
|
||||
stat.total += 1;
|
||||
}
|
||||
|
||||
@@ -585,7 +590,7 @@ app.get('/subjects', async (c) => {
|
||||
if (!map.has(name)) map.set(name, { total: 0, inventory: 0, operating: 0 });
|
||||
const s = map.get(name)!;
|
||||
s.total += 1;
|
||||
if (v.status === 'Inventory' || v.status === 'Abnormal') s.inventory += 1;
|
||||
if (v.status === 'Inventory') s.inventory += 1;
|
||||
if (v.status === 'Operating') s.operating += 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import router from '../vehicles.js';
|
||||
import { vehicleRepository, type FlowDetailRow } from './repository.js';
|
||||
import type { Vehicle } from '../../types.js';
|
||||
|
||||
test('replacement trips are included in delivery counts without inflating totals or duplicating export rows', async (t) => {
|
||||
t.mock.method(vehicleRepository, 'getVehicles', async () => [{ id: '1' } as Vehicle]);
|
||||
const row = (id: string, type: FlowDetailRow['type'], date: string): FlowDetailRow => ({
|
||||
id, type, type_label: type === 'replaced' ? '替换交车' : type === 'delivered' ? '交车' : '还车',
|
||||
stat_date: date, truck_id: '1', plate_number: '测试车牌',
|
||||
event_time: `${date} 12:00:00`, submit_time: '2026-09-01 12:00:00',
|
||||
department: null, manager: null, customer_name: null,
|
||||
});
|
||||
t.mock.method(vehicleRepository, 'getFlowDetailRows', async () => [
|
||||
row('delivered-1', 'delivered', '2026-09-15'),
|
||||
row('replaced-2', 'replaced', '2026-09-15'),
|
||||
row('returned-1', 'returned', '2026-09-15'),
|
||||
row('replaced-3', 'replaced', '2026-09-16'),
|
||||
]);
|
||||
const response = await router.request('/flow-stats?start=2026-09-15&end=2026-09-17');
|
||||
assert.equal(response.status, 200);
|
||||
const data = await response.json();
|
||||
assert.deepEqual(data.totals, { delivered: 3, returned: 1, replaced: 2, total: 4 });
|
||||
assert.deepEqual(data.daily, [
|
||||
{ date: '2026-09-15', delivered: 2, returned: 1, replaced: 1, total: 3 },
|
||||
{ date: '2026-09-16', delivered: 1, returned: 0, replaced: 1, total: 1 },
|
||||
{ date: '2026-09-17', delivered: 0, returned: 0, replaced: 0, total: 0 },
|
||||
]);
|
||||
assert.equal(data.details.length, 4);
|
||||
assert.equal(new Set(data.details.map((x: { id: string }) => x.id)).size, 4);
|
||||
assert.ok(data.details.filter((x: { type: string }) => x.type === 'replaced')
|
||||
.every((x: { typeLabel: string }) => x.typeLabel === '替换交车'));
|
||||
});
|
||||
|
||||
test('inventory excludes abnormal vehicles in totals, type rollups, regions and drilldowns', async (t) => {
|
||||
const vehicles = ['Inventory', 'Abnormal', 'Pending', 'Operating'].map((status, i) => ({
|
||||
id: String(i), status, operationStatus: status === 'Operating' ? '1' : '3',
|
||||
plateNumber: `测试${i}`, model: '现代4.5T普货', type: '4.5T', location: '广东',
|
||||
subjectOrg: i === 1 ? '乙公司' : '甲公司',
|
||||
} as Vehicle));
|
||||
t.mock.method(vehicleRepository, 'getVehicles', async () => vehicles);
|
||||
t.mock.method(vehicleRepository, 'getWeeklyTruckIds', async () => ({
|
||||
pending: new Set<string>(), delivered: new Set<string>(), returned: new Set<string>(), replaced: new Set<string>(),
|
||||
}));
|
||||
const get = async (path: string) => {
|
||||
const response = await router.request(path);
|
||||
assert.equal(response.status, 200);
|
||||
return response.json();
|
||||
};
|
||||
const summary = await get('/summary');
|
||||
assert.equal(summary.totalAssets, 4);
|
||||
assert.equal(summary.inventory.total, 1);
|
||||
assert.equal(summary.inventory.abnormal, 1);
|
||||
assert.equal(summary.totalAssets, summary.inventory.total + summary.inventory.abnormal + summary.operating.total + summary.pendingDelivery);
|
||||
assert.deepEqual((await get('/list?category=Inventory')).map((v: Vehicle) => v.id), ['0']);
|
||||
assert.deepEqual((await get('/list?category=Abnormal')).map((v: Vehicle) => v.id), ['1']);
|
||||
assert.deepEqual(await get('/list?category=Abnormal&subject=甲公司'), []);
|
||||
const groups = await get('/by-type');
|
||||
assert.equal(groups.reduce((n: number, g: { totalInventory: number }) => n + g.totalInventory, 0), 1);
|
||||
assert.equal(groups.reduce((n: number, g: { totalAbnormal: number }) => n + g.totalAbnormal, 0), 1);
|
||||
const model = groups.flatMap((g: { models: unknown[] }) => g.models).find((m: { abnormal: number }) => m.abnormal === 1);
|
||||
assert.equal(model.inventory, 1);
|
||||
assert.equal(model.inventoryRegions['广东'], 1);
|
||||
const inventory = await get('/inventory-stats');
|
||||
assert.equal(inventory.reduce((n: number, row: { quantity: number }) => n + row.quantity, 0), 1);
|
||||
});
|
||||
@@ -213,8 +213,9 @@ test('preserves inventory, weekly-flow and mileage aggregation boundaries', () =
|
||||
});
|
||||
assert.deepEqual(getStats(vehicles, weeklyIds), {
|
||||
total: 4,
|
||||
inventory: 2,
|
||||
inventoryRegions: { 嘉兴: 1, 广东: 0, 北京: 1, 新疆: 0, 其他: 0 },
|
||||
inventory: 1,
|
||||
abnormal: 1,
|
||||
inventoryRegions: { 嘉兴: 1, 广东: 0, 北京: 0, 新疆: 0, 其他: 0 },
|
||||
pending: 1,
|
||||
operating: 1,
|
||||
weeklyDelivered: 1,
|
||||
|
||||
@@ -110,7 +110,52 @@ test('keeps weekly detail SQL selection and flow date parameters unchanged', asy
|
||||
{ sql: WEEKLY_DETAIL_SQL.new, values: undefined },
|
||||
{
|
||||
sql: FLOW_STATS_SQL,
|
||||
values: ['2026-08-01', '2026-08-13', '2026-08-01', '2026-08-13', '2026-08-01', '2026-08-13'],
|
||||
values: ['2026-08-01', '2026-08-13', '2026-08-01', '2026-08-13'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('flow dates and inclusive day boundaries use operations signing times, retaining creation time only for display', () => {
|
||||
for (const [alias, field] of [['dv', 'delivery_completion_processed_time'], ['r', 'return_completion_processed_time']]) {
|
||||
assert.ok(FLOW_STATS_SQL.includes(`DATE_FORMAT(${alias}.${field}, '%Y-%m-%d') AS stat_date`));
|
||||
assert.ok(FLOW_STATS_SQL.includes(`AND ${alias}.${field} IS NOT NULL`));
|
||||
assert.ok(FLOW_STATS_SQL.includes(`AND ${alias}.${field} >= ?`));
|
||||
assert.ok(FLOW_STATS_SQL.includes(`AND ${alias}.${field} < DATE_ADD(?, INTERVAL 1 DAY)`));
|
||||
assert.ok(FLOW_STATS_SQL.includes(`DATE_FORMAT(${alias}.create_time, '%Y-%m-%d %H:%i:%s') AS submit_time`));
|
||||
}
|
||||
assert.doesNotMatch(FLOW_STATS_SQL, /AND \w+\.create_time/);
|
||||
assert.ok(FLOW_STATS_SQL.includes('ORDER BY flow.event_time DESC, flow.id DESC'));
|
||||
});
|
||||
|
||||
test('flow retains multiple business events for the same vehicle', async () => {
|
||||
const events = [
|
||||
{ id: 'delivered-1', truck_id: '1', type: 'delivered', stat_date: '2026-09-16' },
|
||||
{ id: 'delivered-2', truck_id: '1', type: 'delivered', stat_date: '2026-09-16' },
|
||||
{ id: 'returned-1', truck_id: '1', type: 'returned', stat_date: '2026-09-16' },
|
||||
];
|
||||
const repository = new VehicleRepository(new FakeDatabase([events]));
|
||||
assert.deepEqual(await repository.getFlowDetailRows('2026-09-16', '2026-09-16'), events);
|
||||
});
|
||||
|
||||
|
||||
test('replacement trips come exclusively from linked delivery records', () => {
|
||||
assert.ok(FLOW_STATS_SQL.includes("CASE WHEN dv.vehicle_replacement_id IS NOT NULL THEN 'replaced' ELSE 'delivered' END AS type"));
|
||||
assert.ok(FLOW_STATS_SQL.includes("CASE WHEN dv.vehicle_replacement_id IS NOT NULL THEN '替换交车' ELSE '交车' END AS type_label"));
|
||||
assert.equal((FLOW_STATS_SQL.match(/FROM delivery_vehicle/g) || []).length, 1);
|
||||
assert.doesNotMatch(FLOW_STATS_SQL, /FROM vehicle_replacement|dv\.delivery_time|r\.arrival_time|vr\.replace_time/);
|
||||
});
|
||||
|
||||
test('completed delivery and replacement trips use vehicle status and exclude suspended subjects', () => {
|
||||
const [deliveries, returns] = FLOW_STATS_SQL.split('UNION ALL');
|
||||
assert.ok(deliveries.includes('AND dv.delivery_status IN (2,3,20)'));
|
||||
assert.ok(deliveries.includes('AND (dts.status IS NULL OR dts.status <> 10)'));
|
||||
assert.doesNotMatch(deliveries, /dts\.status\s*(?:=|IN)|dv\.delivery_status IN \(2,3,5\)/);
|
||||
assert.ok(returns.includes('AND r.status IN (2,3,5)'));
|
||||
});
|
||||
|
||||
test('return history includes completed, signed and awaiting-signature statuses without arrival or settlement requirements', () => {
|
||||
const returns = FLOW_STATS_SQL.split('UNION ALL')[1];
|
||||
assert.ok(returns.includes('AND r.status IN (2,3,5)'));
|
||||
assert.ok(returns.includes('AND r.return_completion_processed_time >= ?'));
|
||||
assert.doesNotMatch(returns, /r\.is_arrived|r\.arrival_time|settlement|AND dts\.status|AND \(dts\.status/);
|
||||
});
|
||||
|
||||
@@ -231,13 +231,13 @@ export const FLOW_STATS_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,
|
||||
CONCAT(CASE WHEN dv.vehicle_replacement_id IS NOT NULL THEN 'replaced-' ELSE 'delivered-' END, dv.id) AS id,
|
||||
CASE WHEN dv.vehicle_replacement_id IS NOT NULL THEN 'replaced' ELSE 'delivered' END AS type,
|
||||
CASE WHEN dv.vehicle_replacement_id IS NOT NULL THEN '替换交车' ELSE '交车' END AS type_label,
|
||||
DATE_FORMAT(dv.delivery_completion_processed_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.delivery_completion_processed_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,
|
||||
@@ -251,10 +251,11 @@ export const FLOW_STATS_SQL = `
|
||||
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)
|
||||
AND dv.delivery_completion_processed_time IS NOT NULL
|
||||
AND dv.delivery_status IN (2,3,20)
|
||||
AND (dts.status IS NULL OR dts.status <> 10)
|
||||
AND dv.delivery_completion_processed_time >= ?
|
||||
AND dv.delivery_completion_processed_time < DATE_ADD(?, INTERVAL 1 DAY)
|
||||
|
||||
UNION ALL
|
||||
|
||||
@@ -262,10 +263,10 @@ export const FLOW_STATS_SQL = `
|
||||
CONCAT('returned-', r.id) AS id,
|
||||
'returned' AS type,
|
||||
'还车' AS type_label,
|
||||
DATE_FORMAT(r.create_time, '%Y-%m-%d') AS stat_date,
|
||||
DATE_FORMAT(r.return_completion_processed_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.return_completion_processed_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,
|
||||
@@ -279,40 +280,14 @@ export const FLOW_STATS_SQL = `
|
||||
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.return_completion_processed_time IS NOT NULL
|
||||
-- 与还车管理历史记录一致:完成、已签章、待签署;日期按运维签章时间归属。
|
||||
AND r.status IN (2,3,5)
|
||||
AND r.create_time >= ?
|
||||
AND r.create_time < DATE_ADD(?, INTERVAL 1 DAY)
|
||||
AND r.return_completion_processed_time >= ?
|
||||
AND r.return_completion_processed_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
|
||||
ORDER BY flow.event_time DESC, flow.id DESC
|
||||
`;
|
||||
|
||||
const CACHE_TTL = 60 * 1000;
|
||||
@@ -420,7 +395,7 @@ export class VehicleRepository {
|
||||
}
|
||||
|
||||
async getFlowDetailRows(start: string, end: string): Promise<FlowDetailRow[]> {
|
||||
const [rows] = await this.db.query<any[]>(FLOW_STATS_SQL, [start, end, start, end, start, end]);
|
||||
const [rows] = await this.db.query<any[]>(FLOW_STATS_SQL, [start, end, start, end]);
|
||||
return rows as FlowDetailRow[];
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface WeeklyTruckIds {
|
||||
export interface VehicleStats {
|
||||
total: number;
|
||||
inventory: number;
|
||||
abnormal: number;
|
||||
inventoryRegions: Record<string, number>;
|
||||
pending: number;
|
||||
operating: number;
|
||||
|
||||
@@ -28,10 +28,11 @@ export function getRegionCounts(vehicles: Vehicle[], regions: readonly string[])
|
||||
|
||||
export function getStats(list: Vehicle[], weeklyIds?: WeeklyTruckIds): VehicleStats {
|
||||
const ids = list.map((vehicle) => String(vehicle.id));
|
||||
const inventory = list.filter((vehicle) => vehicle.status === 'Inventory' || vehicle.status === 'Abnormal');
|
||||
const inventory = list.filter((vehicle) => vehicle.status === 'Inventory');
|
||||
return {
|
||||
total: list.length,
|
||||
inventory: inventory.length,
|
||||
abnormal: list.filter((vehicle) => vehicle.status === 'Abnormal').length,
|
||||
inventoryRegions: getRegionCounts(inventory, REGIONS),
|
||||
pending: list.filter((vehicle) => vehicle.status === 'Pending').length,
|
||||
operating: list.filter((vehicle) => vehicle.status === 'Operating').length,
|
||||
|
||||
@@ -81,6 +81,7 @@ export interface TypeSummary {
|
||||
type: string;
|
||||
totalAssets: number;
|
||||
totalInventory: number;
|
||||
totalAbnormal: number;
|
||||
totalOperating: number;
|
||||
inventoryRegions: Record<string, number>;
|
||||
pending: number;
|
||||
@@ -94,6 +95,7 @@ export interface ModelSummary {
|
||||
model: string;
|
||||
total: number;
|
||||
inventory: number;
|
||||
abnormal: number;
|
||||
inventoryRegions: Record<string, number>;
|
||||
pending: number;
|
||||
operating: number;
|
||||
@@ -107,6 +109,7 @@ export interface BatchSummary {
|
||||
batch: string;
|
||||
total: number;
|
||||
inventory: number;
|
||||
abnormal: number;
|
||||
inventoryRegions: Record<string, number>;
|
||||
pending: number;
|
||||
operating: number;
|
||||
@@ -119,6 +122,7 @@ export interface BatchGroup {
|
||||
batch: string;
|
||||
total: number;
|
||||
inventory: number;
|
||||
abnormal: number;
|
||||
inventoryRegions: Record<string, number>;
|
||||
pending: number;
|
||||
operating: number;
|
||||
@@ -130,6 +134,7 @@ export interface BatchGroup {
|
||||
type: string;
|
||||
total: number;
|
||||
inventory: number;
|
||||
abnormal: number;
|
||||
inventoryRegions: Record<string, number>;
|
||||
pending: number;
|
||||
operating: number;
|
||||
|
||||
+23
-17
@@ -1,3 +1,7 @@
|
||||
# 凭据通过 Woodpecker Secret 注入(HARBOR_USERNAME / HARBOR_PASSWORD),
|
||||
# 不再把 Harbor 账号密码 base64 后写进流水线脚本。
|
||||
# 镜像推送只在 push / manual 触发:pull_request 不应向生产 registry 推送。
|
||||
|
||||
steps:
|
||||
- name: npm-build
|
||||
image: node:22-alpine
|
||||
@@ -10,15 +14,19 @@ steps:
|
||||
- master
|
||||
- develop
|
||||
- main
|
||||
- feature/asset-statistics
|
||||
commands: |
|
||||
cd $CI_WORKSPACE
|
||||
npm ci
|
||||
set -eu
|
||||
cd "$CI_WORKSPACE"
|
||||
node --version
|
||||
npm --version
|
||||
npm ci --registry=https://registry.npmmirror.com/ --no-audit --no-fund --loglevel=verbose
|
||||
npm run lint
|
||||
npm test
|
||||
npm run build
|
||||
|
||||
# 获取分支名
|
||||
BRANCH_NAME=$(echo $CI_COMMIT_BRANCH | tr / -)
|
||||
BRANCH_NAME=$(echo ${CI_COMMIT_BRANCH:-manual} | tr / -)
|
||||
echo "Branch name: $BRANCH_NAME"
|
||||
|
||||
# 版本号: 分支名-package.json版本
|
||||
@@ -29,36 +37,34 @@ steps:
|
||||
|
||||
- name: docker-build
|
||||
image: docker:24.0.5-cli
|
||||
depends_on: [npm-build]
|
||||
when:
|
||||
event:
|
||||
- push
|
||||
- pull_request
|
||||
- manual
|
||||
branch:
|
||||
- master
|
||||
- develop
|
||||
- main
|
||||
- feature/asset-statistics
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
environment:
|
||||
HARBOR_USERNAME:
|
||||
from_secret: HARBOR_USERNAME
|
||||
HARBOR_PASSWORD:
|
||||
from_secret: HARBOR_PASSWORD
|
||||
commands: |
|
||||
PROJECT_VERSION=$(cat $CI_WORKSPACE/project_version.txt)
|
||||
MODULE_NAME=ln-bi
|
||||
REGISTRY=harbor.lnh2e.com
|
||||
PROJECT=lingniu-v1
|
||||
|
||||
echo "Building Docker image: $MODULE_NAME:$PROJECT_VERSION"
|
||||
|
||||
cd $CI_WORKSPACE
|
||||
docker build -t $REGISTRY/$PROJECT/$MODULE_NAME:$PROJECT_VERSION .
|
||||
|
||||
docker build -t harbor.lnh2e.com/lingniu-v1/$MODULE_NAME:$PROJECT_VERSION .
|
||||
echo "$HARBOR_PASSWORD" | docker login $REGISTRY -u "$HARBOR_USERNAME" --password-stdin
|
||||
|
||||
mkdir -p /root/.docker
|
||||
cat > /root/.docker/config.json <<EOF
|
||||
{
|
||||
"auths": {
|
||||
"harbor.lnh2e.com": {
|
||||
"auth": "$(echo Y2ljZDpMbkBjaWNkMDE=)"
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
docker push harbor.lnh2e.com/lingniu-v1/$MODULE_NAME:$PROJECT_VERSION
|
||||
docker push $REGISTRY/$PROJECT/$MODULE_NAME:$PROJECT_VERSION
|
||||
|
||||
Reference in New Issue
Block a user