8 Commits
Author SHA1 Message Date
shishengliang c2f21a3a8a fix: restore post-v1.1.14 features while preserving asset updates
ci/woodpecker/push/woodpecker Pipeline was successful
Undo the full-tree rollback in 6ea1b3d and restore the pre-rollback v1.2.0 code. Preserve the new asset flow rules, abnormal inventory separation, range drilldowns and date picker; adapt the regression test to the restored routes layout.
2026-09-16 10:48:32 +08:00
shishengliang 17b084ce62 fix(ci): restore npm mirror for dependency installs
ci/woodpecker/push/woodpecker Pipeline was canceled
2026-09-16 10:46:05 +08:00
shishengliang dae727dfde Merge feature/asset-statistics into main
ci/woodpecker/push/woodpecker Pipeline was canceled
2026-09-16 10:38:20 +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
shishengliang 6ea1b3d5dd revert: restore v1.1.14 (37a9f303)
ci/woodpecker/push/woodpecker Pipeline failed
2026-09-16 09:29:30 +08:00
shishengliang c49a02e3d9 fix: restore local mileage monitoring queries
ci/woodpecker/push/woodpecker Pipeline was successful
2026-09-16 09:10:39 +08:00
shishengliang d7538ed6eb chore(deploy): compose 镜像标签更新到 main-1.2.0
ci/woodpecker/push/woodpecker Pipeline was successful
镜像标签此前停留在 main-1.1.15,而 package.json 已升到 1.2.0,
CI 按 `${分支}-${版本}` 产出的标签是 main-1.2.0(Harbor 已存在该标签)。
不更新会继续部署旧镜像,待核对相关修复无法生效。
2026-09-11 12:07:50 +08:00
shishengliang 6822eafd09 ci(woodpecker): Harbor 凭据改用 HARBOR_USERNAME / HARBOR_PASSWORD 注入
ci/woodpecker/push/woodpecker Pipeline was successful
原流水线从 harbor_user / harbor_password 两个 secret 取值,与 Woodpecker
中实际登记的 HARBOR_USERNAME / HARBOR_PASSWORD 名称不一致,docker login
拿不到账号。统一为同名注入,并同步登录命令中的变量名。
2026-09-11 11:54:28 +08:00
26 changed files with 434 additions and 130 deletions
+4
View File
@@ -12,3 +12,7 @@ DB_PORT=3306
DB_USER= DB_USER=
DB_PASSWORD= DB_PASSWORD=
DB_NAME= DB_NAME=
# Local development uses the public OneOS endpoint; the ECS private IP is not reachable locally.
ONEOS_MILEAGE_API_BASE_URL=https://open.d.lnoneos.com
ONEOS_MILEAGE_API_KEY=
ONEOS_MILEAGE_API_TIMEOUT_MS=20000
+2 -2
View File
@@ -2,7 +2,7 @@ FROM node:22-alpine AS builder
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci --registry=https://registry.npmmirror.com/ RUN npm ci --registry=https://registry.npmmirror.com/ --no-audit --no-fund
COPY . . COPY . .
RUN npm run build RUN npm run build
@@ -10,7 +10,7 @@ FROM node:22-alpine
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci --omit=dev --registry=https://registry.npmmirror.com/ RUN npm ci --omit=dev --registry=https://registry.npmmirror.com/ --no-audit --no-fund
COPY --from=builder /app/dist ./dist COPY --from=builder /app/dist ./dist
COPY src/server ./src/server COPY src/server ./src/server
COPY src/shared ./src/shared COPY src/shared ./src/shared
+1 -1
View File
@@ -6,7 +6,7 @@ version: '3.8'
services: services:
ln-bi: ln-bi:
image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.1.15 image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.2.0
network_mode: host network_mode: host
environment: environment:
NODE_ENV: "production" NODE_ENV: "production"
+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';
@@ -86,7 +87,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[]>([]);
@@ -383,11 +384,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">
@@ -65,6 +65,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[];
} }
+1 -1
View File
@@ -52,7 +52,7 @@ export interface RangeMileageResult {
} }
async function fetchTargetRows(): Promise<TargetRow[]> { async function fetchTargetRows(): Promise<TargetRow[]> {
return loadMonitoringTargetRows(pool).then(([rows]) => rows as TargetRow[]); return loadMonitoringTargetRows(pool).then(rows => rows as unknown as TargetRow[]);
} }
export async function refreshMonitoringCache(): Promise<void> { export async function refreshMonitoringCache(): Promise<void> {
@@ -0,0 +1,45 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import pool from '../../db/mysql.js';
import monitoring from './monitoring.js';
import { clearOneOsMileageCache } from './oneos-api.js';
test('single-day monitoring merges all target rows and handles an empty target table', async (t) => {
const previousUrl = process.env.ONEOS_MILEAGE_API_BASE_URL;
const previousKey = process.env.ONEOS_MILEAGE_API_KEY;
process.env.ONEOS_MILEAGE_API_BASE_URL = 'https://oneos.test';
process.env.ONEOS_MILEAGE_API_KEY = 'test-key';
t.after(() => {
if (previousUrl === undefined) delete process.env.ONEOS_MILEAGE_API_BASE_URL;
else process.env.ONEOS_MILEAGE_API_BASE_URL = previousUrl;
if (previousKey === undefined) delete process.env.ONEOS_MILEAGE_API_KEY;
else process.env.ONEOS_MILEAGE_API_KEY = previousKey;
clearOneOsMileageCache();
});
let targets = [
{ id: 1, target_name: '目标A', plate_number: '粤A10001' },
{ id: 2, target_name: '目标B', plate_number: '粤A10001' },
];
t.mock.method(pool, 'query', async (sql: string) => {
if (sql.includes('tab_mileage_assessment_target')) return [targets, []];
assert.ok(sql.includes('FROM vehicle_info vi'));
return [[{ plate: '粤A10001', vin: 'test-vin' }], []];
});
t.mock.method(globalThis, 'fetch', async (_url: unknown, init: RequestInit) => {
const { date } = JSON.parse(String(init.body));
return Response.json({ code: 'SUCCESS', data: [{
plateNumber: '粤A10001', date, status: 'NORMAL',
dailyMileageKm: 12, totalMileageKm: 100, sourceProtocol: 'GB32960',
}] });
});
for (const expectedTargets of [['目标A', '目标B'], []]) {
clearOneOsMileageCache();
const response = await monitoring.request('/?startDate=2026-09-16&endDate=2026-09-16&sourcePriority=instrument');
assert.equal(response.status, 200);
const payload = await response.json();
assert.equal(payload.total, 1);
assert.equal(payload.vehicles[0].dailyKm, 12);
assert.deepEqual(payload.vehicles[0].targetNames, expectedTargets);
targets = [];
}
});
+67
View File
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import router from './routes.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[];
} }
+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;
} }
+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;
+12 -7
View File
@@ -1,4 +1,4 @@
# 凭据通过 Woodpecker Secret 注入(harbor_user / harbor_password), # 凭据通过 Woodpecker Secret 注入(HARBOR_USERNAME / HARBOR_PASSWORD),
# 不再把 Harbor 账号密码 base64 后写进流水线脚本。 # 不再把 Harbor 账号密码 base64 后写进流水线脚本。
# 镜像推送只在 push / manual 触发:pull_request 不应向生产 registry 推送。 # 镜像推送只在 push / manual 触发:pull_request 不应向生产 registry 推送。
@@ -14,9 +14,13 @@ steps:
- master - master
- develop - develop
- main - main
- feature/asset-statistics
commands: | commands: |
cd $CI_WORKSPACE set -eu
npm ci --registry=https://registry.npmmirror.com/ 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
@@ -42,13 +46,14 @@ steps:
- 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: environment:
HARBOR_USER: HARBOR_USERNAME:
from_secret: harbor_user from_secret: HARBOR_USERNAME
HARBOR_PASSWORD: HARBOR_PASSWORD:
from_secret: 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
@@ -60,6 +65,6 @@ steps:
cd $CI_WORKSPACE cd $CI_WORKSPACE
docker build -t $REGISTRY/$PROJECT/$MODULE_NAME:$PROJECT_VERSION . docker build -t $REGISTRY/$PROJECT/$MODULE_NAME:$PROJECT_VERSION .
echo "$HARBOR_PASSWORD" | docker login $REGISTRY -u "$HARBOR_USER" --password-stdin echo "$HARBOR_PASSWORD" | docker login $REGISTRY -u "$HARBOR_USERNAME" --password-stdin
docker push $REGISTRY/$PROJECT/$MODULE_NAME:$PROJECT_VERSION docker push $REGISTRY/$PROJECT/$MODULE_NAME:$PROJECT_VERSION