Author SHA1 Message Date
shishengliang 9c13ab6d3b fix(energy): use new hydrogen site master data
ci/woodpecker/push/woodpecker Pipeline was successful
Use new_hydrogen_site for daily and overview station names and regional aggregation, with empty-name fallback. Include the OneOS mileage reconciliation investigation report.
2026-09-16 11:54:27 +08:00
shishengliang 1e4afacac7 ci: sync Woodpecker configuration from main
ci/woodpecker/push/woodpecker Pipeline was successful
2026-09-16 10:49:39 +08:00
shishengliang 0c26b973ef feat(assets): align flow statistics and separate abnormal inventory
ci/woodpecker/push/woodpecker Pipeline failed
Use operations signing times and delivery completion statuses; include replacement deliveries in delivery totals with range drilldowns and exports. Separate abnormal vehicles from inventory, add a date range picker, and enable CI for feature/asset-statistics.
2026-09-16 10:35:50 +08:00
25 changed files with 610 additions and 156 deletions
@@ -0,0 +1,198 @@
# OneOS 日里程与累计里程差额排查报告
- 排查日期:2026-09-16Asia/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 粤AGQ83779 月 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 粤AGR68399 月 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 粤AGP36099 月 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 原始报文、计算服务源码或内部存储,不能确定其内部算法根因。
- 本次未修改业务代码、未变更里程口径、未触发历史数据重算。
+5 -4
View File
@@ -37,6 +37,7 @@ import {
getWeeklyFlowRange, getWeeklyFlowRange,
selectFlowDetails, selectFlowDetails,
type VehicleModalSelection, type VehicleModalSelection,
type FlowSelection,
} from './model'; } from './model';
import { SearchSelect } from '../../components/SearchSelect'; import { SearchSelect } from '../../components/SearchSelect';
import { MultiSearchSelect } from '../../components/MultiSearchSelect'; import { MultiSearchSelect } from '../../components/MultiSearchSelect';
@@ -87,7 +88,7 @@ export default function AssetsModule() {
const [flowStats, setFlowStats] = useState<FlowStatsResponse | null>(null); const [flowStats, setFlowStats] = useState<FlowStatsResponse | null>(null);
const [flowLoading, setFlowLoading] = useState(false); const [flowLoading, setFlowLoading] = useState(false);
const [flowDailyExpanded, setFlowDailyExpanded] = 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 // Dept/Region/Customer data
const [deptData, setDeptData] = useState<DeptGroup[]>([]); const [deptData, setDeptData] = useState<DeptGroup[]>([]);
@@ -384,11 +385,11 @@ export default function AssetsModule() {
const source = rows ?? flowStats?.details ?? []; const source = rows ?? flowStats?.details ?? [];
if (source.length === 0) return; if (source.length === 0) return;
const table = source.map((item) => ({ const table = source.map((item) => ({
日期: item.date, 业务日期: item.date,
类型: item.typeLabel, 类型: item.typeLabel,
车牌: item.plateNumber, 车牌: item.plateNumber,
流转时间: item.eventTime || '', 运维签章时间: item.eventTime || '',
提交时间: item.submitTime || '', 建单时间: item.submitTime || '',
部门: item.department || '', 部门: item.department || '',
业务负责人: item.manager || '', 业务负责人: item.manager || '',
客户: item.customerName || '', 客户: item.customerName || '',
@@ -2,17 +2,18 @@ import React from 'react';
import { Download, X } from 'lucide-react'; import { Download, X } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react'; import { AnimatePresence, motion } from 'motion/react';
import type { FlowDetailItem, FlowType } from '../api'; import type { FlowDetailItem, FlowType } from '../api';
import type { FlowSelection } from '../model';
export const FLOW_META: Record<FlowType, { label: string; tone: string; chip: string }> = { 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' }, 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' }, 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 { interface FlowDetailModalProps {
selectedFlow: { date: string; type: FlowType } | null; selectedFlow: FlowSelection | null;
selectedFlowDetails: FlowDetailItem[]; 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; exportFlowDetails: (rows?: FlowDetailItem[], title?: string) => void;
} }
export function FlowDetailModal({ export function FlowDetailModal({
@@ -21,6 +22,9 @@ export function FlowDetailModal({
setSelectedFlow, setSelectedFlow,
exportFlowDetails, exportFlowDetails,
}: FlowDetailModalProps) { }: FlowDetailModalProps) {
const dateLabel = selectedFlow
? ('date' in selectedFlow ? selectedFlow.date : `${selectedFlow.start}${selectedFlow.end}`)
: '';
return ( return (
<> <>
{/* Flow Detail Modal */} {/* 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="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 className="flex items-start justify-between gap-3">
<div> <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"> <h3 className="mt-1 text-lg font-black">
{selectedFlow.date} · {FLOW_META[selectedFlow.type].label} {dateLabel} · {FLOW_META[selectedFlow.type].label}
</h3> </h3>
<div className="mt-1 text-[12px] font-bold text-slate-300"> <div className="mt-1 text-[12px] font-bold text-slate-300">
{selectedFlowDetails.length} {selectedFlowDetails.length}
</div> </div>
</div> </div>
<button <button
type="button" type="button"
aria-label="关闭流转明细"
onClick={() => setSelectedFlow(null)} 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" 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"> <div className="mt-3 flex flex-wrap gap-2">
<button <button
type="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} 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" 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"> <thead className="bg-slate-50 text-[11px] font-black text-slate-400">
<tr> <tr>
<th className="w-28 px-3 py-3"></th> <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-36 px-3 py-3"></th>
<th className="w-28 px-3 py-3"></th> <th className="w-28 px-3 py-3"></th>
<th className="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"> <tbody className="divide-y divide-slate-100 text-[12px] font-bold text-slate-700">
{selectedFlowDetails.map((item) => ( {selectedFlowDetails.map((item) => (
<tr key={item.id} className="hover:bg-blue-50/40"> <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-slate-500">{item.eventTime || '-'}</td>
<td className="px-3 py-3 text-blue-600">{item.submitTime || '-'}</td> <td className="px-3 py-3 text-blue-600">{item.submitTime || '-'}</td>
<td className="px-3 py-3">{item.department || '-'}</td> <td className="px-3 py-3">{item.department || '-'}</td>
@@ -105,13 +110,13 @@ export function FlowDetailModal({
</div> </div>
</div> </div>
<div className="text-right text-[10px] font-bold text-slate-400"> <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 className="mt-0.5 text-[11px] text-blue-600">{item.submitTime?.slice(5, 16) || '-'}</div>
</div> </div>
</div> </div>
<div className="mt-3 grid grid-cols-2 gap-2 text-[11px]"> <div className="mt-3 grid grid-cols-2 gap-2 text-[11px]">
<div className="rounded-xl bg-slate-50 p-2"> <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 className="mt-1 font-bold text-slate-700">{item.eventTime || '-'}</div>
</div> </div>
<div className="rounded-xl bg-slate-50 p-2"> <div className="rounded-xl bg-slate-50 p-2">
@@ -66,6 +66,7 @@ export function VehicleDetailModal({
showPlateNumbers.category === 'Delivered' ? '本周已交车' : showPlateNumbers.category === 'Delivered' ? '本周已交车' :
showPlateNumbers.category === 'Returned' ? '已还车' : showPlateNumbers.category === 'Returned' ? '已还车' :
showPlateNumbers.category === 'Replaced' ? '已替换' : showPlateNumbers.category === 'Replaced' ? '已替换' :
showPlateNumbers.category === 'Abnormal' ? '异动' :
showPlateNumbers.category === 'Inventory' ? `${showPlateNumbers.location}库存` : showPlateNumbers.category === 'Inventory' ? `${showPlateNumbers.location}库存` :
showPlateNumbers.category === 'Operating' ? '正在运营' : '全部状态'} showPlateNumbers.category === 'Operating' ? '正在运营' : '全部状态'}
</p> </p>
@@ -14,7 +14,7 @@ export function AssetSummaryDesktopTable({
}: AssetSummarySectionProps) { }: AssetSummarySectionProps) {
return ( return (
<div className="hidden lg:block overflow-x-auto"> <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> <thead>
<tr className="bg-gray-50 text-[11px] text-gray-500 uppercase tracking-wider border-b border-gray-100"> <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> <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 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 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-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> <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' 'bg-blue-50/50 hover:bg-blue-50 transition-colors'
}`} }`}
onClick={() => toggleAssetType(typeGroup.type)}> 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 justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{expandedAssetTypes.has(typeGroup.type) ? {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'}`}> <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-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={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> <span> <span className={theme === 'vibrant' ? 'font-bold text-white' : 'font-bold text-green-600'}>{typeGroup.totalOperating}</span></span>
</div> </div>
</div> </div>
@@ -119,6 +121,12 @@ export function AssetSummaryDesktopTable({
) : ''} ) : ''}
</td> </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"> <td className="p-3 text-center border-r border-gray-100">
{model.pending > 0 ? ( {model.pending > 0 ? (
<button <button
@@ -33,6 +33,7 @@ export function AssetSummaryMobileCards({
<div className={`flex gap-3 text-[9px] font-normal ${theme === 'vibrant' ? 'opacity-90' : 'text-gray-500'}`}> <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-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={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> <span> <span className={theme === 'vibrant' ? 'font-bold' : 'font-bold text-green-600'}>{typeGroup.totalOperating}</span></span>
</div> </div>
</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" />} {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> <span className="text-xs font-bold text-gray-700">{model.model}</span>
</div> </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-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> <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> <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>
</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 type { FlowType } from '../../api';
import { FLOW_META } from '../FlowDetailModal'; import { FLOW_META } from '../FlowDetailModal';
import type { FlowStatisticsCardProps } from './types'; import type { FlowStatisticsCardProps } from './types';
import { FlowDateRangePicker } from './FlowDateRangePicker';
const FLOW_TYPES: FlowType[] = ['delivered', 'returned', 'replaced']; const FLOW_TYPES: FlowType[] = ['delivered', 'returned', 'replaced'];
@@ -23,7 +24,7 @@ export function FlowStatisticsCard({
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2 text-[13px] font-black text-slate-700"> <div className="flex items-center gap-2 text-[13px] font-black text-slate-700">
<CalendarDays size={14} className="text-blue-500" /> <CalendarDays size={14} className="text-blue-500" />
<span></span> <span></span>
{flowLoading && <Loader2 size={12} className="animate-spin text-slate-400" />} {flowLoading && <Loader2 size={12} className="animate-spin text-slate-400" />}
</div> </div>
</div> </div>
@@ -39,37 +40,8 @@ export function FlowStatisticsCard({
</button> </button>
</div> </div>
</div> </div>
<div className="mt-2 rounded-xl border border-slate-100 bg-slate-50/80 px-2 py-1.5"> <p className="mt-1 text-[11px] text-slate-400"></p>
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2"> <FlowDateRangePicker value={flowRange} onChange={setFlowRange} />
<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>
<div className="mt-2 rounded-2xl border border-slate-100 bg-slate-50/70 px-2 py-2.5"> <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="grid grid-cols-4 items-center text-center">
<div className="px-2"> <div className="px-2">
@@ -77,12 +49,16 @@ export function FlowStatisticsCard({
<div className="mt-1 text-[10px] font-black text-slate-400"></div> <div className="mt-1 text-[10px] font-black text-slate-400"></div>
</div> </div>
{FLOW_TYPES.map((type) => ( {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'}`}> <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} {flowStats?.totals[type] ?? 0}
</div> </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>
</div> </button>
))} ))}
</div> </div>
</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 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="flex items-center justify-between">
<div className="text-[11px] font-black text-slate-400">{day.date}</div> <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>
<div className="mt-2 grid grid-cols-4 items-center text-center"> <div className="mt-2 grid grid-cols-4 items-center text-center">
<div className="px-2"> <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" 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={`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> </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'; import type { SummaryMetricsProps } from './types';
export function SummaryMetrics({ export function SummaryMetrics({
@@ -11,7 +11,7 @@ export function SummaryMetrics({
return ( return (
<> <>
{/* Header Summary - Ultra Compact */} {/* 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 */} {/* 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" <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: '资产概览' })}> 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="text-[9px] text-gray-400 font-medium uppercase leading-none mb-0.5"></div>
<div className="flex items-baseline gap-1"> <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-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> </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 */} {/* 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" <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: '待交车' })}> 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 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="flex items-center justify-between gap-3">
<div className="shrink-0 text-[11px] font-black text-slate-400"></div> <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="px-2">
<div className="text-[10px] font-black text-slate-400"></div> <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> <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="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 className="mt-0.5 text-base font-black text-slate-700">{inventoryRate.toFixed(1)}%</div>
</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="px-2">
<div className="text-[10px] font-black text-slate-400"></div> <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> <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 { FlowDetailItem, FlowStatsResponse, FlowType } from '../../api';
import type { import type {
DateRange, DateRange,
FlowSelection,
InventoryFilters, InventoryFilters,
VehicleModalSelection, VehicleModalSelection,
} from '../../model'; } from '../../model';
@@ -24,7 +25,7 @@ export interface FlowStatisticsCardProps {
exportFlowDetails: (rows?: FlowDetailItem[], title?: string) => void; exportFlowDetails: (rows?: FlowDetailItem[], title?: string) => void;
flowDailyExpanded: boolean; flowDailyExpanded: boolean;
setFlowDailyExpanded: Dispatch<SetStateAction<boolean>>; setFlowDailyExpanded: Dispatch<SetStateAction<boolean>>;
setSelectedFlow: Dispatch<SetStateAction<{ date: string; type: FlowType } | null>>; setSelectedFlow: Dispatch<SetStateAction<FlowSelection | null>>;
} }
export interface AssetSummarySectionProps { export interface AssetSummarySectionProps {
+14
View File
@@ -296,6 +296,20 @@ test('流转明细按日期和类型同时筛选,客户饼图按区域汇总',
['1'], ['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 = [ const customers = [
customer({ region: '广东', total: 2 }), customer({ region: '广东', total: 2 }),
customer({ customer: '客户乙', region: '浙江', total: 5 }), customer({ customer: '客户乙', region: '浙江', total: 5 }),
+10 -6
View File
@@ -50,6 +50,7 @@ export interface ModalVehicleFilters {
export type VehicleModalCategory = export type VehicleModalCategory =
| 'Inventory' | 'Inventory'
| 'Abnormal'
| 'Pending' | 'Pending'
| 'Delivered' | 'Delivered'
| 'Returned' | 'Returned'
@@ -77,7 +78,7 @@ export interface VehicleListRequestParams {
batch?: string; batch?: string;
model?: string; model?: string;
location?: string; location?: string;
category?: 'Inventory' | 'Operating' | 'Pending'; category?: 'Inventory' | 'Abnormal' | 'Operating' | 'Pending';
vehicleType?: string; vehicleType?: string;
manager?: string; manager?: string;
customer?: string; customer?: string;
@@ -97,10 +98,9 @@ export type VehicleModalRequest =
} }
| { kind: 'vehicles'; params: VehicleListRequestParams }; | { kind: 'vehicles'; params: VehicleListRequestParams };
export interface FlowSelection { export type FlowSelection =
date: string; | { date: string; type: FlowType }
type: FlowType; | { start: string; end: string; type: FlowType };
}
export function formatLocalDateTime(date: Date): string { export function formatLocalDateTime(date: Date): string {
const year = date.getFullYear(); const year = date.getFullYear();
@@ -172,6 +172,7 @@ export function buildVehicleModalRequest(
if (selection.model !== 'All') params.model = selection.model; if (selection.model !== 'All') params.model = selection.model;
if (selection.location !== 'All') params.location = selection.location; if (selection.location !== 'All') params.location = selection.location;
if (selection.source) params.source = selection.source; if (selection.source) params.source = selection.source;
if (selection.category === 'Abnormal') params.category = 'Abnormal';
if (selection.category === 'Inventory') params.category = 'Inventory'; if (selection.category === 'Inventory') params.category = 'Inventory';
if (selection.category === 'Operating') params.category = 'Operating'; if (selection.category === 'Operating') params.category = 'Operating';
if (selection.category === 'Pending') params.category = 'Pending'; if (selection.category === 'Pending') params.category = 'Pending';
@@ -391,7 +392,10 @@ export function selectFlowDetails(
) { ) {
if (!flowStats || !selection) return []; if (!flowStats || !selection) return [];
return flowStats.details.filter((detail) => ( 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'))
)); ));
} }
+6
View File
@@ -24,6 +24,7 @@ export interface BatchSummary {
batch: string; batch: string;
total: number; total: number;
inventory: number; inventory: number;
abnormal: number;
inventoryRegions: Record<string, number>; inventoryRegions: Record<string, number>;
pending: number; pending: number;
operating: number; operating: number;
@@ -36,6 +37,7 @@ export interface ModelSummary {
model: string; model: string;
total: number; total: number;
inventory: number; inventory: number;
abnormal: number;
inventoryRegions: Record<string, number>; inventoryRegions: Record<string, number>;
pending: number; pending: number;
operating: number; operating: number;
@@ -49,6 +51,7 @@ export interface TypeSummary {
type: string; type: string;
totalAssets: number; totalAssets: number;
totalInventory: number; totalInventory: number;
totalAbnormal: number;
totalOperating: number; totalOperating: number;
inventoryRegions: Record<string, number>; inventoryRegions: Record<string, number>;
pending: number; pending: number;
@@ -62,6 +65,7 @@ export interface BatchGroup {
batch: string; batch: string;
total: number; total: number;
inventory: number; inventory: number;
abnormal: number;
inventoryRegions: Record<string, number>; inventoryRegions: Record<string, number>;
pending: number; pending: number;
operating: number; operating: number;
@@ -73,6 +77,7 @@ export interface BatchGroup {
type: string; type: string;
total: number; total: number;
inventory: number; inventory: number;
abnormal: number;
inventoryRegions: Record<string, number>; inventoryRegions: Record<string, number>;
pending: number; pending: number;
operating: number; operating: number;
@@ -153,6 +158,7 @@ export interface RegionTypeBreakdown {
total: number; total: number;
operating: number; operating: number;
inventory: number; inventory: number;
abnormal: number;
pending: number; pending: number;
customers: string[]; customers: string[];
} }
+2 -2
View File
@@ -48,14 +48,14 @@ export function registerHydrogenDailyRoute(
const [stationRows] = await hydrogenPool.query<RowDataPacket[]>( const [stationRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS d, `SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS d,
COALESCE(b.station_id, 0) AS stationId, 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 '未关联站点' CASE WHEN MAX(b.station_id) IS NULL THEN '未关联站点'
ELSE CONCAT('未知站点 #', MAX(b.station_id)) END) AS stationName, ELSE CONCAT('未知站点 #', MAX(b.station_id)) END) AS stationName,
ROUND(SUM(b.amount_kg), 2) AS kg, ROUND(SUM(b.amount_kg), 2) AS kg,
-- 单价:直接取订单中的成本价(不重算)。MAX 自然忽略 0 元的免费/赠送单 -- 单价:直接取订单中的成本价(不重算)。MAX 自然忽略 0 元的免费/赠送单
MAX(b.cost_price) AS pricePerKg MAX(b.cost_price) AS pricePerKg
FROM ${HYDROGEN_TABLE} b 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} WHERE ${where}
GROUP BY d, COALESCE(b.station_id, 0) GROUP BY d, COALESCE(b.station_id, 0)
ORDER BY d DESC, kg DESC`, ORDER BY d DESC, kg DESC`,
+13 -13
View File
@@ -119,13 +119,13 @@ export function registerHydrogenOverviewRoute(
// Top5 加氢站(指定年份) // Top5 加氢站(指定年份)
const [top5Rows] = await hydrogenPool.query<RowDataPacket[]>( const [top5Rows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT b.station_id AS id, `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 '未关联站点' CASE WHEN b.station_id IS NULL THEN '未关联站点'
ELSE CONCAT('未知站点 #', b.station_id) END) AS name, ELSE CONCAT('未知站点 #', b.station_id) END) AS name,
SUM(b.amount_kg) AS kg, SUM(b.amount_kg) AS kg,
SUM(b.cost_total) AS fee SUM(b.cost_total) AS fee
FROM ${HYDROGEN_TABLE} b 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} WHERE ${HYDROGEN_BASE_WHERE_B}
AND b.${HYDROGEN_LOCAL} >= ? AND b.${HYDROGEN_LOCAL} >= ?
AND YEAR(b.${HYDROGEN_LOCAL}) = ? AND YEAR(b.${HYDROGEN_LOCAL}) = ?
@@ -146,13 +146,13 @@ export function registerHydrogenOverviewRoute(
// 加氢站全量汇总(同年所有站,按加氢量降序) // 加氢站全量汇总(同年所有站,按加氢量降序)
const [stationFullRows] = await hydrogenPool.query<RowDataPacket[]>( const [stationFullRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT b.station_id AS id, `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 '未关联站点' CASE WHEN b.station_id IS NULL THEN '未关联站点'
ELSE CONCAT('未知站点 #', b.station_id) END) AS name, ELSE CONCAT('未知站点 #', b.station_id) END) AS name,
SUM(b.amount_kg) AS kg, SUM(b.amount_kg) AS kg,
SUM(b.fee_total) AS revenue SUM(b.fee_total) AS revenue
FROM ${HYDROGEN_TABLE} b 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} WHERE ${HYDROGEN_BASE_WHERE_B}
AND b.${HYDROGEN_LOCAL} >= ? AND b.${HYDROGEN_LOCAL} >= ?
AND YEAR(b.${HYDROGEN_LOCAL}) = ? AND YEAR(b.${HYDROGEN_LOCAL}) = ?
@@ -174,18 +174,18 @@ export function registerHydrogenOverviewRoute(
const [regionRows] = await hydrogenPool.query<RowDataPacket[]>( const [regionRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT region, SUM(kg) AS kg FROM ( `SELECT region, SUM(kg) AS kg FROM (
SELECT CASE SELECT CASE
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%嘉兴%' OR COALESCE(s.station_name, b.station_name, '') LIKE '%平湖%' THEN '嘉兴' 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(s.station_name, b.station_name, '') LIKE '%广州%' THEN '广州' WHEN COALESCE(NULLIF(s.site_name, ''), NULLIF(b.station_name, ''), '') LIKE '%广州%' THEN '广州'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%佛山%' THEN '佛山' WHEN COALESCE(NULLIF(s.site_name, ''), NULLIF(b.station_name, ''), '') LIKE '%佛山%' THEN '佛山'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%成都%' THEN '成都' WHEN COALESCE(NULLIF(s.site_name, ''), NULLIF(b.station_name, ''), '') LIKE '%成都%' THEN '成都'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%重庆%' THEN '重庆' WHEN COALESCE(NULLIF(s.site_name, ''), NULLIF(b.station_name, ''), '') LIKE '%重庆%' THEN '重庆'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%乌鲁木齐%' THEN '乌鲁木齐' WHEN COALESCE(NULLIF(s.site_name, ''), NULLIF(b.station_name, ''), '') LIKE '%乌鲁木齐%' THEN '乌鲁木齐'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%昆山%' THEN '昆山' WHEN COALESCE(NULLIF(s.site_name, ''), NULLIF(b.station_name, ''), '') LIKE '%昆山%' THEN '昆山'
ELSE COALESCE(NULLIF(s.station_name, ''), NULLIF(b.station_name, ''), '未知') ELSE COALESCE(NULLIF(s.site_name, ''), NULLIF(b.station_name, ''), '未知')
END AS region, END AS region,
b.amount_kg AS kg b.amount_kg AS kg
FROM ${HYDROGEN_TABLE} b 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} WHERE ${HYDROGEN_BASE_WHERE_B}
AND b.${HYDROGEN_LOCAL} >= ? AND b.${HYDROGEN_LOCAL} >= ?
AND YEAR(b.${HYDROGEN_LOCAL}) = ? AND YEAR(b.${HYDROGEN_LOCAL}) = ?
+5 -5
View File
@@ -61,7 +61,7 @@ test('能源路由继续按原顺序注册四个 GET 接口', () => {
); );
}); });
test('四个处理器继续使用原缓存语义、数据库、SQL 与参数', async () => { test('四个处理器保持缓存和参数契约,加氢站点使用新主数据表', async () => {
const hydrogenCalls: QueryCall[] = []; const hydrogenCalls: QueryCall[] = [];
const electricCalls: QueryCall[] = []; const electricCalls: QueryCall[] = [];
const cacheCalls: CacheCall[] = []; const cacheCalls: CacheCall[] = [];
@@ -133,12 +133,12 @@ test('四个处理器继续使用原缓存语义、数据库、SQL 与参数', a
[ [
'95f6d99b61c9', '95f6d99b61c9',
'63a63c5f78bb', '63a63c5f78bb',
'1367eddb3b7f', '9cf6719a39c3',
'83a52a7f9336', '089d8a10d666',
'f28fa4a3cb08', '256ac8309d5a',
'dac9e86fc527', 'dac9e86fc527',
'a71da8f05765', 'a71da8f05765',
'b562668859ce', '24b23780a259',
'226fd8f950a1', '226fd8f950a1',
'6e4031b10926', '6e4031b10926',
'4fc77463294c', '4fc77463294c',
+10 -5
View File
@@ -70,7 +70,7 @@ app.get('/summary', async (c) => {
hanging: 0, hanging: 0,
}, },
inventory: { 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, inStock: vehicles.filter((v) => v.status === 'Inventory').length,
abnormal: vehicles.filter((v) => v.status === 'Abnormal').length, abnormal: vehicles.filter((v) => v.status === 'Abnormal').length,
}, },
@@ -111,6 +111,7 @@ app.get('/by-type', async (c) => {
type: t.name, type: t.name,
totalAssets: typeVehicles.length, totalAssets: typeVehicles.length,
totalInventory: typeStats.inventory, totalInventory: typeStats.inventory,
totalAbnormal: typeStats.abnormal,
totalOperating: typeStats.operating, totalOperating: typeStats.operating,
inventoryRegions: typeStats.inventoryRegions, inventoryRegions: typeStats.inventoryRegions,
pending: typeStats.pending, pending: typeStats.pending,
@@ -273,6 +274,7 @@ app.get('/region-stats', async (c) => {
total: tv.length, total: tv.length,
operating: tv.filter((v) => v.status === 'Operating').length, operating: tv.filter((v) => v.status === 'Operating').length,
inventory: tv.filter((v) => v.status === 'Inventory').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, pending: tv.filter((v) => v.status === 'Pending').length,
customers: Array.from(new Set(tv.map((v) => v.customerName).filter(Boolean))) as string[], 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) {
if (category === 'Inventory') { 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') { } else if (category === 'Operating') {
filtered = filtered.filter((v) => v.status === 'Operating'); filtered = filtered.filter((v) => v.status === 'Operating');
} else if (category === 'Pending') { } else if (category === 'Pending') {
@@ -461,7 +465,7 @@ app.get('/list', async (c) => {
// GET /api/vehicles/inventory-stats — 库存统计,不设数据权限,对所有人开放 // GET /api/vehicles/inventory-stats — 库存统计,不设数据权限,对所有人开放
app.get('/inventory-stats', async (c) => { app.get('/inventory-stats', async (c) => {
const vehicles = applySubjectFilter(c, await vehicleRepository.getVehicles()); 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> = { const TYPE_NAME_MAP: Record<string, string> = {
t4_5: '4.5T普货', 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 // GET /api/vehicles/flow-stats?start=YYYY-MM-DD&end=YYYY-MM-DD
// 资产流转日报:按提交时间(create_time)统计交车、还车、替换车,并返回可点击明细 // 资产流转日报:按运维签章时间统计业务车次;交车包含关联替换单的记录,替换作为其中的子项,同车多笔业务分别累计
app.get('/flow-stats', async (c) => { app.get('/flow-stats', async (c) => {
const { start, end } = normalizeDateRange(c.req.query('start'), c.req.query('end')); const { start, end } = normalizeDateRange(c.req.query('start'), c.req.query('end'));
const allowedVehicles = await getVehiclesForUser(c); const allowedVehicles = await getVehiclesForUser(c);
@@ -555,6 +559,7 @@ app.get('/flow-stats', async (c) => {
const stat = dailyMap.get(item.date); const stat = dailyMap.get(item.date);
if (!stat) continue; if (!stat) continue;
stat[item.type] += 1; stat[item.type] += 1;
if (item.type === 'replaced') stat.delivered += 1;
stat.total += 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 }); if (!map.has(name)) map.set(name, { total: 0, inventory: 0, operating: 0 });
const s = map.get(name)!; const s = map.get(name)!;
s.total += 1; 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; if (v.status === 'Operating') s.operating += 1;
} }
+67
View File
@@ -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);
});
+3 -2
View File
@@ -213,8 +213,9 @@ test('preserves inventory, weekly-flow and mileage aggregation boundaries', () =
}); });
assert.deepEqual(getStats(vehicles, weeklyIds), { assert.deepEqual(getStats(vehicles, weeklyIds), {
total: 4, total: 4,
inventory: 2, inventory: 1,
inventoryRegions: { 嘉兴: 1, 广东: 0, 北京: 1, 新疆: 0, 其他: 0 }, abnormal: 1,
inventoryRegions: { 嘉兴: 1, 广东: 0, 北京: 0, 新疆: 0, 其他: 0 },
pending: 1, pending: 1,
operating: 1, operating: 1,
weeklyDelivered: 1, weeklyDelivered: 1,
+46 -1
View File
@@ -110,7 +110,52 @@ test('keeps weekly detail SQL selection and flow date parameters unchanged', asy
{ sql: WEEKLY_DETAIL_SQL.new, values: undefined }, { sql: WEEKLY_DETAIL_SQL.new, values: undefined },
{ {
sql: FLOW_STATS_SQL, 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/);
});
+18 -43
View File
@@ -231,13 +231,13 @@ export const FLOW_STATS_SQL = `
SELECT * SELECT *
FROM ( FROM (
SELECT SELECT
CONCAT('delivered-', dv.id) AS id, CONCAT(CASE WHEN dv.vehicle_replacement_id IS NOT NULL THEN 'replaced-' ELSE 'delivered-' END, dv.id) AS id,
'delivered' AS type, CASE WHEN dv.vehicle_replacement_id IS NOT NULL THEN 'replaced' ELSE 'delivered' END AS type,
'交车' AS type_label, CASE WHEN dv.vehicle_replacement_id IS NOT NULL THEN '替换交车' ELSE '交车' END AS type_label,
DATE_FORMAT(dv.create_time, '%Y-%m-%d') AS stat_date, DATE_FORMAT(dv.delivery_completion_processed_time, '%Y-%m-%d') AS stat_date,
CAST(dv.vehicle_id AS CHAR) AS truck_id, CAST(dv.vehicle_id AS CHAR) AS truck_id,
dv.plate_number, 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, DATE_FORMAT(dv.create_time, '%Y-%m-%d %H:%i:%s') AS submit_time,
c.business_department_name AS department, c.business_department_name AS department,
c.business_manager_name AS manager, c.business_manager_name AS manager,
@@ -251,10 +251,11 @@ export const FLOW_STATS_SQL = `
AND c.del_flag = '0' AND c.del_flag = '0'
WHERE dv.del_flag = '0' WHERE dv.del_flag = '0'
AND dv.vehicle_id IS NOT NULL AND dv.vehicle_id IS NOT NULL
AND dv.create_time IS NOT NULL AND dv.delivery_completion_processed_time IS NOT NULL
AND dv.delivery_status IN (2,3,5) AND dv.delivery_status IN (2,3,20)
AND dv.create_time >= ? AND (dts.status IS NULL OR dts.status <> 10)
AND dv.create_time < DATE_ADD(?, INTERVAL 1 DAY) AND dv.delivery_completion_processed_time >= ?
AND dv.delivery_completion_processed_time < DATE_ADD(?, INTERVAL 1 DAY)
UNION ALL UNION ALL
@@ -262,10 +263,10 @@ export const FLOW_STATS_SQL = `
CONCAT('returned-', r.id) AS id, CONCAT('returned-', r.id) AS id,
'returned' AS type, 'returned' AS type,
'还车' AS type_label, '还车' 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, CAST(r.vehicle_id AS CHAR) AS truck_id,
r.plate_number, 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, DATE_FORMAT(r.create_time, '%Y-%m-%d %H:%i:%s') AS submit_time,
c.business_department_name AS department, c.business_department_name AS department,
c.business_manager_name AS manager, c.business_manager_name AS manager,
@@ -279,40 +280,14 @@ export const FLOW_STATS_SQL = `
AND c.del_flag = '0' AND c.del_flag = '0'
WHERE r.del_flag = '0' WHERE r.del_flag = '0'
AND r.vehicle_id IS NOT NULL 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.status IN (2,3,5)
AND r.create_time >= ? AND r.return_completion_processed_time >= ?
AND r.create_time < DATE_ADD(?, INTERVAL 1 DAY) 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 ) flow
ORDER BY flow.submit_time DESC ORDER BY flow.event_time DESC, flow.id DESC
`; `;
const CACHE_TTL = 60 * 1000; const CACHE_TTL = 60 * 1000;
@@ -420,7 +395,7 @@ export class VehicleRepository {
} }
async getFlowDetailRows(start: string, end: string): Promise<FlowDetailRow[]> { 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[]; return rows as FlowDetailRow[];
} }
+1
View File
@@ -12,6 +12,7 @@ export interface WeeklyTruckIds {
export interface VehicleStats { export interface VehicleStats {
total: number; total: number;
inventory: number; inventory: number;
abnormal: number;
inventoryRegions: Record<string, number>; inventoryRegions: Record<string, number>;
pending: number; pending: number;
operating: number; operating: number;
+2 -1
View File
@@ -28,10 +28,11 @@ export function getRegionCounts(vehicles: Vehicle[], regions: readonly string[])
export function getStats(list: Vehicle[], weeklyIds?: WeeklyTruckIds): VehicleStats { export function getStats(list: Vehicle[], weeklyIds?: WeeklyTruckIds): VehicleStats {
const ids = list.map((vehicle) => String(vehicle.id)); 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 { return {
total: list.length, total: list.length,
inventory: inventory.length, inventory: inventory.length,
abnormal: list.filter((vehicle) => vehicle.status === 'Abnormal').length,
inventoryRegions: getRegionCounts(inventory, REGIONS), inventoryRegions: getRegionCounts(inventory, REGIONS),
pending: list.filter((vehicle) => vehicle.status === 'Pending').length, pending: list.filter((vehicle) => vehicle.status === 'Pending').length,
operating: list.filter((vehicle) => vehicle.status === 'Operating').length, operating: list.filter((vehicle) => vehicle.status === 'Operating').length,
+5
View File
@@ -81,6 +81,7 @@ export interface TypeSummary {
type: string; type: string;
totalAssets: number; totalAssets: number;
totalInventory: number; totalInventory: number;
totalAbnormal: number;
totalOperating: number; totalOperating: number;
inventoryRegions: Record<string, number>; inventoryRegions: Record<string, number>;
pending: number; pending: number;
@@ -94,6 +95,7 @@ export interface ModelSummary {
model: string; model: string;
total: number; total: number;
inventory: number; inventory: number;
abnormal: number;
inventoryRegions: Record<string, number>; inventoryRegions: Record<string, number>;
pending: number; pending: number;
operating: number; operating: number;
@@ -107,6 +109,7 @@ export interface BatchSummary {
batch: string; batch: string;
total: number; total: number;
inventory: number; inventory: number;
abnormal: number;
inventoryRegions: Record<string, number>; inventoryRegions: Record<string, number>;
pending: number; pending: number;
operating: number; operating: number;
@@ -119,6 +122,7 @@ export interface BatchGroup {
batch: string; batch: string;
total: number; total: number;
inventory: number; inventory: number;
abnormal: number;
inventoryRegions: Record<string, number>; inventoryRegions: Record<string, number>;
pending: number; pending: number;
operating: number; operating: number;
@@ -130,6 +134,7 @@ export interface BatchGroup {
type: string; type: string;
total: number; total: number;
inventory: number; inventory: number;
abnormal: number;
inventoryRegions: Record<string, number>; inventoryRegions: Record<string, number>;
pending: number; pending: number;
operating: number; operating: number;
+23 -17
View File
@@ -1,3 +1,7 @@
# 凭据通过 Woodpecker Secret 注入(HARBOR_USERNAME / HARBOR_PASSWORD),
# 不再把 Harbor 账号密码 base64 后写进流水线脚本。
# 镜像推送只在 push / manual 触发:pull_request 不应向生产 registry 推送。
steps: steps:
- name: npm-build - name: npm-build
image: node:22-alpine image: node:22-alpine
@@ -10,15 +14,19 @@ steps:
- master - master
- develop - develop
- main - main
- feature/asset-statistics
commands: | commands: |
cd $CI_WORKSPACE set -eu
npm ci cd "$CI_WORKSPACE"
node --version
npm --version
npm ci --registry=https://registry.npmmirror.com/ --no-audit --no-fund --loglevel=verbose
npm run lint npm run lint
npm test npm test
npm run build npm run build
# 获取分支名 # 获取分支名
BRANCH_NAME=$(echo $CI_COMMIT_BRANCH | tr / -) BRANCH_NAME=$(echo ${CI_COMMIT_BRANCH:-manual} | tr / -)
echo "Branch name: $BRANCH_NAME" echo "Branch name: $BRANCH_NAME"
# 版本号: 分支名-package.json版本 # 版本号: 分支名-package.json版本
@@ -29,36 +37,34 @@ steps:
- name: docker-build - name: docker-build
image: docker:24.0.5-cli image: docker:24.0.5-cli
depends_on: [npm-build]
when: when:
event: event:
- push - push
- pull_request
- manual - manual
branch: branch:
- master - master
- develop - develop
- main - main
- feature/asset-statistics
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
environment:
HARBOR_USERNAME:
from_secret: HARBOR_USERNAME
HARBOR_PASSWORD:
from_secret: HARBOR_PASSWORD
commands: | commands: |
PROJECT_VERSION=$(cat $CI_WORKSPACE/project_version.txt) PROJECT_VERSION=$(cat $CI_WORKSPACE/project_version.txt)
MODULE_NAME=ln-bi MODULE_NAME=ln-bi
REGISTRY=harbor.lnh2e.com
PROJECT=lingniu-v1
echo "Building Docker image: $MODULE_NAME:$PROJECT_VERSION" echo "Building Docker image: $MODULE_NAME:$PROJECT_VERSION"
cd $CI_WORKSPACE 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 docker push $REGISTRY/$PROJECT/$MODULE_NAME:$PROJECT_VERSION
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