feat(assets): refine compact inventory drilldowns and exact subtotal scopes

This commit is contained in:
shishengliang
2026-09-23 17:52:33 +08:00
parent d20bc71efe
commit b5a48b6417
15 changed files with 534 additions and 111 deletions
+6 -1
View File
@@ -37,6 +37,7 @@ export async function fetchByType(subject?: string | null): Promise<TypeSummary[
}
export async function fetchVehicleList(params: {
inventoryKeys?: string[];
brand?: string;
batch?: string;
model?: string;
@@ -69,7 +70,11 @@ export async function fetchVehicleList(params: {
if (params.attendance) query.set('attendance', params.attendance);
if (params.subject) query.set('subject', params.subject);
if (params.source) query.set('source', params.source);
return fetchJson<VehicleListItem[]>(`${BASE}/list?${query.toString()}`);
return fetchJson<VehicleListItem[]>(`${BASE}/list?${query.toString()}`, params.inventoryKeys ? {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ inventoryKeys: params.inventoryKeys }),
} : undefined);
}
export interface WeeklyDetailItem {
@@ -67,14 +67,14 @@ export function VehicleDetailModal({
)}
</h3>
<p className="text-[10px] opacity-60 mt-0.5">
{showPlateNumbers.model === 'All' ? '全量型号' : showPlateNumbers.model} |
{showPlateNumbers.scopeLabel || <>{showPlateNumbers.model === 'All' ? '全量型号' : showPlateNumbers.model} |
{showPlateNumbers.category === 'Pending' ? '待交车' :
showPlateNumbers.category === 'Delivered' ? '本周已交车' :
showPlateNumbers.category === 'Returned' ? '已还车' :
showPlateNumbers.category === 'Replaced' ? '已替换' :
showPlateNumbers.category === 'Abnormal' ? '异动' :
showPlateNumbers.category === 'Inventory' ? `${showPlateNumbers.location}库存` :
showPlateNumbers.category === 'Operating' ? '正在运营' : '全部状态'}
showPlateNumbers.category === 'Operating' ? '正在运营' : '全部状态'}</>}
</p>
</div>
<button aria-label="关闭车辆明细" onClick={close} className="hover:bg-white/10 p-2 rounded-full transition-colors">
@@ -2,8 +2,8 @@ import { Fragment, type Dispatch, type SetStateAction } from 'react';
import { ArrowUpRight, ChevronRight } from 'lucide-react';
import { InventoryStatusCounts } from './InventoryStatusCounts';
import { InventoryModelTags } from './InventoryModelTags';
import { inventoryBrandGroups, inventorySelection, type InventoryDisplayGroup } from './inventory-presentation';
import type { InventoryFilters, VehicleModalSelection } from '../../model';
import { inventoryBrandGroups, inventoryRegionRows, inventorySelection, inventorySubtotalSelection, inventoryTotal, type InventoryDisplayGroup } from './inventory-presentation';
import { inventoryTypeLabel, type InventoryFilters, type VehicleModalSelection } from '../../model';
import type { RegionalInventoryStats } from '../../types';
export interface InventoryListProps {
@@ -23,13 +23,13 @@ export function InventoryQuantity({ quantity, label, onClick }: { quantity: numb
}
export function selectInventoryRow(row: RegionalInventoryStats, select: InventoryListProps['select']) {
select(inventorySelection(row, `库存统计 · ${row.city} · ${row.model}`));
select(inventorySelection(row, `库存统计 · ${row.city} · ${row.model}`, { vehicleType: row.type }));
}
export function selectInventoryGroup(group: InventoryDisplayGroup, { tab, filters, select }: Pick<InventoryListProps, 'tab' | 'filters' | 'select'>) {
select(inventorySelection(filters, `库存统计 · ${group.name}`, tab === 'region'
select(inventorySelection(tab === 'model' ? { ...filters, type: group.name } : filters, `库存统计 · ${group.name}`, tab === 'region'
? { location: filters.city || group.name }
: { vehicleType: group.name }));
: {}));
}
export function InventoryShare({ quantity, total }: { quantity: number; total: number }) {
@@ -37,16 +37,26 @@ export function InventoryShare({ quantity, total }: { quantity: number; total: n
return <div className="inventory-share"><span className="inventory-share-track" aria-hidden="true"><span style={{ width: `${percent}%` }} /></span><span>{percent.toFixed(1)}%</span></div>;
}
function InventorySubtotal({ rows, label, select }: { rows: RegionalInventoryStats[]; label: string; select: InventoryListProps['select'] }) {
return <div className={`inventory-subtotal${rows.length === 1 ? ' inventory-subtotal-inline' : ''}`} aria-label={`${label}合计 ${inventoryTotal(rows)} 台`}>
<button className="inventory-subtotal-button" disabled={!inventoryTotal(rows)} aria-label={`${label}合计 ${inventoryTotal(rows)} 台,查看车辆明细`} onClick={() => select(inventorySubtotalSelection(rows, label))}>合计 <strong>{inventoryTotal(rows).toLocaleString()}</strong> 台<ArrowUpRight size={12} aria-hidden="true" /></button>
<InventoryStatusCounts rows={rows} />
</div>;
}
function InventoryDetailRows({ rows, city, tab, total, select }: Pick<InventoryListProps, 'tab' | 'total' | 'select'> & { rows: RegionalInventoryStats[]; city?: string }) {
const brands = inventoryBrandGroups(rows, tab === 'region' ? 'type' : 'model');
return brands.map((brand, brandIndex) => brand.variants.map((variants, variantIndex) => variants.map((row, index) =>
return brands.map((brand, brandIndex) => brand.variants.map((variants, variantIndex) =>
(tab === 'model' ? inventoryRegionRows(variants) : variants.map(row => ({ row, regionSpan: 0 }))).map(({ row, regionSpan }, index) =>
<tr className="inventory-detail-row" key={`${brandIndex}-${variantIndex}-${index}`}>
{tab === 'region' && brandIndex === 0 && variantIndex === 0 && index === 0 && <th rowSpan={rows.length} scope="rowgroup" className="inventory-merged-cell inventory-city-cell">{city || '未分配'}</th>}
{variantIndex === 0 && index === 0 && <td rowSpan={brand.rows.length} className="inventory-merged-cell">{brand.brand || '—'}</td>}
{tab === 'region' && brandIndex === 0 && variantIndex === 0 && index === 0 && <th rowSpan={rows.length} scope="rowgroup" className="inventory-merged-cell inventory-city-cell">{city || '未分配'}<InventorySubtotal select={select} rows={rows} label={city || '未分配'} /></th>}
{variantIndex === 0 && index === 0 && <td rowSpan={brand.rows.length} className="inventory-merged-cell">{brand.brand || '—'}<InventorySubtotal select={select} rows={brand.rows} label={brand.brand || '未分配品牌'} /></td>}
{index === 0 && <td rowSpan={variants.length} className="inventory-merged-cell">
{tab === 'region' ? <span className="inventory-tag inventory-tag-type">{row.type || '未分类'}</span> : <InventoryModelTags row={row} hideType />}
{tab === 'region' ? <span className="inventory-tag inventory-tag-type">{inventoryTypeLabel(row.type) || '未分类'}</span> : <InventoryModelTags row={row} hideType />}
<InventorySubtotal select={select} rows={variants} label={tab === 'region' ? inventoryTypeLabel(row.type) : row.model} />
</td>}
<td>{tab === 'region' ? <InventoryModelTags row={row} hideType /> : <><span className="inventory-place-region">{row.region}</span><span>{row.city}</span></>}</td>
{tab === 'model' && regionSpan > 0 && <td rowSpan={regionSpan} className="inventory-merged-cell inventory-region-cell">{row.region || '未分配'}<InventorySubtotal select={select} rows={variants.filter(item => item.region === row.region)} label={row.region || '未分配'} /></td>}
<td>{tab === 'region' ? <InventoryModelTags row={row} hideType /> : row.city || '未分配'}</td>
<td className="inventory-number-column"><InventoryQuantity quantity={row.quantity} label={`${row.city} · ${row.brand} · ${row.model}`} onClick={() => selectInventoryRow(row, select)} /><InventoryStatusCounts rows={[row]} /></td>
<td><InventoryShare quantity={row.quantity} total={total} /></td>
</tr>
@@ -62,14 +72,14 @@ export function InventoryDesktopTable(props: InventoryListProps) {
{tab === 'region' && <th scope="col">区域 / 城市</th>}
<th scope="col">品牌</th>
{tab === 'region' && <th scope="col">车型</th>}
<th scope="col">类别 / 颜色 / 归属方</th>
{tab === 'model' && <th scope="col">区域 / 城市</th>}
<th scope="col">类别</th>
{tab === 'model' && <><th scope="col">区域</th><th scope="col">城市</th></>}
<th scope="col" className="inventory-number-column">库存数量</th>
<th scope="col" className="inventory-share-column">库存占比</th>
</tr></thead>
{groups.map(group => <Fragment key={group.name}><tbody>
<tr className="inventory-group-row">
<th scope="row" colSpan={tab === 'region' ? 4 : 3}>
<tr className="inventory-group-row" data-expanded={expanded.has(group.name)}>
<th scope="row" colSpan={4}>
<div className="inventory-group-heading">
<button className="inventory-expand" aria-expanded={expanded.has(group.name)} onClick={() => toggle(group.name)}>
<ChevronRight size={16} aria-hidden="true" /><strong>{group.name || '未分配'}</strong><span>{group.sections.length} 个{tab === 'region' ? '城市' : '型号'}</span>
@@ -1,37 +1,77 @@
import { ChevronRight } from 'lucide-react';
import { Fragment, useId, useRef, useState } from 'react';
import { ChevronLeft, ChevronRight, MapPin, Truck } from 'lucide-react';
import { InventoryStatusCounts } from './InventoryStatusCounts';
import { InventoryModelTags } from './InventoryModelTags';
import { inventoryBrandGroups } from './inventory-presentation';
import { InventoryQuantity, InventoryShare, selectInventoryGroup, selectInventoryRow, type InventoryListProps } from './InventoryDesktopTable';
import { inventoryMobileDrilldown, inventoryMobileGroups, inventoryPage, inventorySubtotalSelection, inventoryTotal, type InventoryDisplayGroup } from './inventory-presentation';
import { InventoryQuantity, InventoryShare, selectInventoryGroup, type InventoryListProps } from './InventoryDesktopTable';
function MobileGroupDetails({ group, tab, select }: Pick<InventoryListProps, 'tab' | 'select'> & { group: InventoryDisplayGroup }) {
const [path, setPath] = useState<string[]>([]);
const [requestedPage, setPage] = useState(0);
const heading = useRef<HTMLHeadingElement>(null);
const view = inventoryMobileDrilldown(group.rows, tab, path);
const labels = { region: '区域', city: '城市', type: '车型', variant: '品牌 / 型号' };
const { page, pageCount, start, rows: pageGroups } = inventoryPage(view.groups, requestedPage);
const activePath = view.parents.map(parent => parent.key);
const navigate = (nextPath: string[]) => {
setPath(nextPath);
setPage(0);
heading.current?.focus({ preventScroll: true });
};
const openVehicles = (rows: typeof group.rows, label: string) => select(inventorySubtotalSelection(rows, label));
return <div className="inventory-mobile-details">
{view.parents.length > 0 && <nav className="inventory-mobile-breadcrumb" aria-label="库存下钻路径">
<button onClick={() => navigate([])}>{group.name || '未分配'}</button>
{view.parents.map((parent, index) => <Fragment key={parent.key}>
<ChevronRight size={12} aria-hidden="true" />
{index === view.parents.length - 1 ? <span aria-current="location">{parent.label}</span> :
<button onClick={() => navigate(activePath.slice(0, index + 1))}>{parent.label}</button>}
</Fragment>)}
</nav>}
<div className="inventory-mobile-detail-toolbar">
<h3 ref={heading} tabIndex={-1}>{labels[view.dimension]}库存 <span>· {view.groups.length} 项</span></h3>
{view.parents.length > 0 && <button className="inventory-text-button" onClick={() => navigate(activePath.slice(0, -1))}><ChevronLeft size={14} aria-hidden="true" />返回上一级</button>}
</div>
<div className="inventory-mobile-list-heading"><span>{labels[view.dimension]}</span><span>库存(台)</span></div>
<ul className="inventory-mobile-records" aria-label={`${labels[view.dimension]}库存列表`}>
{pageGroups.map(item => <li key={item.key} className="inventory-mobile-drill-row">
<button className="inventory-mobile-drill-label"
aria-label={view.dimension === 'variant' ? `${item.label},查看车辆明细` : `查看${item.label}的${view.dimension === 'region' ? '城市' : view.dimension === 'city' && tab === 'region' ? '车型' : '品牌型号'}库存`}
onClick={() => view.dimension === 'variant' ? openVehicles(item.rows, item.label) : navigate([...activePath, item.key])}>
<span>{item.label}</span>
{view.dimension !== 'variant' && <ChevronRight size={14} aria-hidden="true" />}
</button>
<InventoryQuantity quantity={item.quantity} label={item.label} onClick={() => openVehicles(item.rows, item.label)} />
{item.rows.some(row => row.abnormal || row.pending) && <div className="inventory-mobile-status"><InventoryStatusCounts rows={item.rows} /></div>}
</li>)}
</ul>
<div className="inventory-mobile-scope-total" role="status"><span>当前范围合计</span><strong>{inventoryTotal(view.rows).toLocaleString()} 台</strong></div>
{pageCount > 1 && <nav className="inventory-mobile-pagination" aria-label={`${group.name}明细分页`}>
<button disabled={page === 0} onClick={() => setPage(page - 1)}><ChevronLeft size={16} aria-hidden="true" />上一页</button>
<span aria-live="polite">{start + 1}–{start + pageGroups.length} / {view.groups.length}</span>
<button disabled={page === pageCount - 1} onClick={() => setPage(page + 1)}>下一页<ChevronRight size={16} aria-hidden="true" /></button>
</nav>}
</div>;
}
export function InventoryMobileList(props: InventoryListProps) {
const { groups, tab, expanded, toggle, total, select } = props;
const { groups, tab, total } = props;
const [activeGroup, setActiveGroup] = useState<string | null>(null);
const id = useId();
return <div className="inventory-mobile">
{groups.map(group => <article key={group.name} className="inventory-mobile-group">
<p className="inventory-mobile-hint">点击名称逐层查看,点击数量查看车辆</p>
{groups.map((group, index) => <article key={group.name} className="inventory-mobile-group" data-expanded={activeGroup === group.name}>
<div className="inventory-mobile-group-heading">
<button className="inventory-expand" aria-expanded={expanded.has(group.name)} onClick={() => toggle(group.name)}>
<ChevronRight size={16} aria-hidden="true" /><strong>{group.name || '未分配'}</strong><span>{group.sections.length} 个{tab === 'region' ? '城市' : '型号'}</span>
<button className="inventory-expand" aria-expanded={activeGroup === group.name} aria-controls={`${id}-${index}`} onClick={() => setActiveGroup(activeGroup === group.name ? null : group.name)}>
<span className="inventory-mobile-group-icon">{tab === 'region' ? <MapPin size={16} aria-hidden="true" /> : <Truck size={16} aria-hidden="true" />}</span><strong>{group.name || '未分配'}</strong><span>{inventoryMobileGroups(group.rows, tab === 'region' ? 'city' : 'region').length} 个{tab === 'region' ? '城市' : '区域'}</span><ChevronRight size={14} aria-hidden="true" />
</button>
<InventoryQuantity quantity={group.quantity} label={group.name} onClick={() => selectInventoryGroup(group, props)} />
</div>
<div className="inventory-mobile-group-meta"><span>库存占比</span><InventoryShare quantity={group.quantity} total={total} /></div>
{(group.rows.some(row => row.abnormal || row.pending)) && <div className="inventory-mobile-status"><InventoryStatusCounts rows={group.rows} /></div>}
{expanded.has(group.name) && <div className="inventory-mobile-details">
{(tab === 'region' ? group.sections : [[group.name, group.rows] as const]).map(([name, rows]) => <section key={name}>
{tab === 'region' && <h3>{name || '未分配'}</h3>}
{inventoryBrandGroups(rows, tab === 'region' ? 'type' : 'model').map(brand => <section className="inventory-mobile-brand" key={brand.brand}>
<h4>{brand.brand || '未分配品牌'}</h4>
{brand.variants.map((variants, variantIndex) => <section className="inventory-mobile-variant" key={variantIndex}>
<div className="inventory-mobile-variant-heading">{tab === 'region' ? <span className="inventory-tag inventory-tag-type">{variants[0].type || '未分类'}</span> : <InventoryModelTags row={variants[0]} hideType />}</div>
{variants.map((row, index) => <div className="inventory-mobile-row" key={index}>
<div>{tab === 'region' ? <InventoryModelTags row={row} hideType /> : <><span className="inventory-place-region">{row.region}</span><strong>{row.city}</strong></>}</div>
<InventoryQuantity quantity={row.quantity} label={`${row.city} · ${row.brand} · ${row.model}`} onClick={() => selectInventoryRow(row, select)} />
{(row.abnormal || row.pending) ? <div className="inventory-mobile-status"><InventoryStatusCounts rows={[row]} /></div> : null}
</div>)}
</section>)}
</section>)}
</section>)}
</div>}
<div id={`${id}-${index}`} hidden={activeGroup !== group.name}>
{activeGroup === group.name && <MobileGroupDetails group={group} tab={tab} select={props.select} />}
</div>
</article>)}
</div>;
}
@@ -43,7 +43,7 @@ export function InventoryStatisticsSection(props: InventoryStatisticsSectionProp
<header className="inventory-heading">
<div className="inventory-title">
<span className="inventory-title-icon"><Boxes size={20} aria-hidden="true" /></span>
<div><h2 id={`${id}-heading`}>库存统计</h2><p>从区域与车型查看库存分布,点击数量查看车辆明细</p></div>
<div><h2 id={`${id}-heading`}>库存统计</h2><p>按区域、车型核对库存分布</p></div>
</div>
<button ref={filterTrigger} className="inventory-control" aria-expanded={filterOpen} aria-controls={`${id}-filters`}
onClick={() => { if (!filterOpen) setDraft({ ...filters }); setFilterOpen(!filterOpen); }}>
@@ -58,7 +58,7 @@ export function InventoryStatisticsSection(props: InventoryStatisticsSectionProp
<span>{activeFilters.length ? '筛选后库存' : '库存总数'}<ArrowUpRight size={16} aria-hidden="true" /></span>
<strong>{total.toLocaleString()}<small>台</small></strong>
</button>
<button className="inventory-metric" disabled={!pending}
<button className="inventory-metric inventory-metric-pending" disabled={!pending}
onClick={() => select(inventorySelection(filters, '库存统计 · 待交车', { category: 'Pending' }))}>
<span>其中待交车<ArrowUpRight size={16} aria-hidden="true" /></span>
<strong>{pending.toLocaleString()}<small>台</small></strong>
@@ -107,13 +107,13 @@ export function InventoryStatisticsSection(props: InventoryStatisticsSectionProp
</div>
<div className="inventory-toolbar-actions">
<label className="inventory-sort"><span className="sr-only">库存分组排序</span><select value={sort} onChange={event => setSort(event.target.value as typeof sort)}><option value="default">默认排序</option><option value="quantity">库存从多到少</option></select></label>
<button className="inventory-text-button" disabled={!groups.length} onClick={() => groups.forEach(group => { if (expanded.has(group.name) === allExpanded) toggle(group.name); })}>
<button className="inventory-text-button inventory-expand-all" disabled={!groups.length} onClick={() => groups.forEach(group => { if (expanded.has(group.name) === allExpanded) toggle(group.name); })}>
{allExpanded ? <ChevronsDownUp size={16} aria-hidden="true" /> : <ChevronsUpDown size={16} aria-hidden="true" />}{allExpanded ? '收起全部' : '展开全部'}
</button>
</div>
</div>
{groups.length ? <><InventoryDesktopTable {...listProps} /><InventoryMobileList {...listProps} /></> :
{groups.length ? <><InventoryDesktopTable {...listProps} /><InventoryMobileList key={JSON.stringify([tab, filters])} {...listProps} /></> :
<div className="inventory-empty"><SearchX size={32} aria-hidden="true" /><h3>{activeFilters.length ? '没有符合条件的库存' : '暂无库存数据'}</h3><p>{activeFilters.length ? '试试减少筛选条件,或清除筛选查看全部库存。' : '当前所属公司范围内暂无库存记录。'}</p>{activeFilters.length > 0 && <button className="inventory-control" onClick={resetFilters}>清除筛选</button>}</div>}
<footer className="inventory-footer"><span role="status" aria-live="polite">共 {groups.length} 个{tab === 'region' ? '区域' : '车型'} · {rows.length} 条记录 · {total.toLocaleString()} 台库存</span><span>占比基于当前筛选结果 · 单位:台</span></footer>
</section>
@@ -6,7 +6,7 @@ export function InventoryStatusCounts({ rows }: { rows: RegionalInventoryStats[]
const pending = rows.reduce((sum, row) => sum + (row.pending ?? 0), 0);
if (abnormal === 0 && pending === 0) return null;
return <span className="inline-flex flex-wrap gap-x-3 gap-y-1 text-[11px] font-normal" data-testid="inventory-status-counts">
{abnormal > 0 && <span className="text-amber-700">其中异动 <strong>{abnormal}</strong> 台</span>}
{pending > 0 && <span className="text-blue-700">其中待交车 <strong>{pending}</strong> 台</span>}
{abnormal > 0 && <span className="inventory-status-abnormal text-amber-700">其中异动 <strong>{abnormal}</strong> 台</span>}
{pending > 0 && <span className="inventory-status-pending text-blue-700">其中待交车 <strong>{pending}</strong> 台</span>}
</span>;
}
@@ -1,11 +1,126 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildVehicleModalRequest, groupInventoryByRegion } from '../../model';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { InventoryStatusCounts } from './InventoryStatusCounts';
import { buildVehicleModalRequest, deriveInventoryView, groupInventoryByRegion, type VehicleModalSelection } from '../../model';
import { selectInventoryGroup, selectInventoryRow } from './InventoryDesktopTable';
import type { RegionalInventoryStats } from '../../types';
import { inventoryBrandGroups, inventoryGroups, inventoryModelTags, inventorySelection } from './inventory-presentation';
import { inventoryBrandGroups, inventoryGroups, inventoryHierarchy, inventoryMobileDrilldown, inventoryMobileGroups, inventoryMobileRows, inventoryModelTags, inventoryPage, inventoryRegionRows, inventorySelection, inventorySubtotalSelection } from './inventory-presentation';
import { inventoryKey } from '../../../../shared/assets/inventory';
import { fetchVehicleList } from '../../api';
const filters = { region: '华东', city: '上海', brand: '品牌甲', type: '18T', model: '批次一' };
test('mobile region navigation shows only cities then types, preserving totals and exact leaf scope', () => {
const rows = [
{ ...filters, batch: '', quantity: 9 },
{ ...filters, brand: '品牌乙', batch: '', quantity: 8 },
{ ...filters, type: '49T', batch: '', quantity: 3 },
{ ...filters, city: '苏州', batch: '', quantity: 2 },
];
const cities = inventoryMobileDrilldown(rows, 'region', []);
assert.equal(cities.dimension, 'city');
assert.deepEqual(cities.groups.map(group => [group.label, group.quantity]), [['上海', 20], ['苏州', 2]]);
const types = inventoryMobileDrilldown(rows, 'region', ['上海']);
assert.equal(types.dimension, 'type');
assert.deepEqual(types.groups.map(group => [group.label, group.quantity]), [['18T', 17], ['49T', 3]]);
const variants = inventoryMobileDrilldown(rows, 'region', ['上海', '18T']);
assert.equal(variants.dimension, 'variant');
assert.deepEqual(variants.groups.map(group => group.quantity), [9, 8]);
assert.deepEqual(variants.groups.flatMap(group => inventorySubtotalSelection(group.rows, group.label).inventoryKeys), rows.slice(0, 2).map(inventoryKey));
assert.deepEqual(inventoryMobileDrilldown(rows, 'region', ['上海']).groups, types.groups, 'back navigation restores the whole parent');
});
test('mobile model navigation isolates same-named cities by region and recovers invalid paths', () => {
const rows = [
{ ...filters, batch: '', quantity: 4 },
{ ...filters, region: '华南', batch: '', quantity: 7 },
{ ...filters, region: '华南', city: '', batch: '', quantity: 1 },
];
assert.equal(inventoryMobileDrilldown(rows, 'model', []).dimension, 'region');
const cities = inventoryMobileDrilldown(rows, 'model', ['华南']);
assert.deepEqual(cities.groups.map(group => [group.label, group.quantity]), [['上海', 7], ['未分配城市', 1]]);
assert.deepEqual(inventoryMobileDrilldown(rows, 'model', ['华南', '上海']).rows, [rows[1]]);
assert.deepEqual(inventoryMobileDrilldown(rows, 'model', ['华南', '']).rows, [rows[2]]);
assert.equal(inventoryMobileDrilldown(rows, 'model', ['华南', '已移除城市']).dimension, 'city');
assert.equal(inventoryMobileDrilldown(rows, 'model', ['已移除区域']).dimension, 'region');
assert.deepEqual(inventoryMobileDrilldown([], 'model', []).groups, []);
});
test('mobile type totals combine brands and other types without merging distinct variants', () => {
const rows = [
{ ...filters, type: '挂车', brand: '帕力安牌', batch: '一批', quantity: 3 },
{ ...filters, type: '其他', brand: '帕力安', batch: '二批', quantity: 4 },
];
assert.deepEqual(inventoryMobileGroups(rows, 'type').map(group => [group.label, group.quantity]), [['其他', 7]]);
const view = inventoryMobileDrilldown(rows, 'region', ['上海', '其他']);
assert.equal(view.groups.length, 2);
assert.deepEqual(view.groups.flatMap(group => group.rows), rows);
});
test('subtotal drilldown sends the exact filtered records, including merged brand aliases and vehicle types', async (t) => {
const rows = [
{ ...filters, brand: '帕力安牌', type: '挂车', batch: '', quantity: 2 },
{ ...filters, brand: '帕力安', type: '其他', model: '其他型号', batch: '', quantity: 1 },
];
const selection = inventorySubtotalSelection(rows, '其他');
const request = buildVehicleModalRequest(selection, '公司甲');
assert.equal(request.kind, 'vehicles');
if (request.kind !== 'vehicles') throw new Error('wrong endpoint');
assert.deepEqual(request.params.inventoryKeys, rows.map(inventoryKey));
assert.equal(request.params.subject, '公司甲');
assert.equal(request.params.category, 'Inventory');
assert.equal(request.params.brand, undefined, 'a merged alias must not restrict the scope to one spelling');
t.mock.method(globalThis, 'fetch', async (url: string, init?: RequestInit) => {
assert.equal(init?.method, 'POST');
assert.deepEqual(JSON.parse(String(init?.body)).inventoryKeys, rows.map(inventoryKey));
assert.equal(new URL(url, 'http://localhost').searchParams.get('subject'), '公司甲');
return new Response('[]', { status: 200 });
});
await fetchVehicleList(request.params);
});
test('hierarchy subtotals stay inside their parent and include records beyond the current page', () => {
const base = { ...filters, batch: '', quantity: 9 };
const rows = [base, { ...base, model: '批次二', quantity: 8 }, { ...base, type: '49T', quantity: 5 }, { ...base, brand: '品牌乙', quantity: 3 }, { ...base, city: '苏州', quantity: 2 }];
assert.deepEqual(inventoryHierarchy(base, rows, 'region').map(level => level.quantity), [25, 22, 17]);
const modelRows = rows.filter(row => row.type === '18T');
assert.deepEqual(inventoryHierarchy(base, modelRows, 'model').map(level => level.quantity), [19, 11, 11]);
assert.deepEqual(inventoryHierarchy(base, [base], 'region').map(level => level.quantity), [9, 9, 9]);
});
test('region merging brings separated cities together without losing their quantities or scope', () => {
const rows = [
{ ...filters, region: '华南', city: '广州', batch: '', quantity: 25 },
{ ...filters, batch: '', quantity: 9 },
{ ...filters, region: '华南', city: '佛山', batch: '', quantity: 1 },
];
const merged = inventoryRegionRows(rows);
assert.deepEqual(merged.map(({ row, regionSpan }) => [row.city, row.quantity, regionSpan]), [['广州', 25, 2], ['佛山', 1, 0], ['上海', 9, 1]]);
assert.deepEqual(rows.map(row => row.city), ['广州', '上海', '佛山']);
assert.equal(merged[1].row, rows[2]);
});
test('mobile pages cover every record once and clamp after a filter reduces the rows', () => {
const rows = Array.from({ length: 19 }, (_, index) => ({ ...filters, model: `型号${index}`, batch: '', quantity: index + 1 }));
const group = inventoryGroups(groupInventoryByRegion(rows), 'default')[0];
const ordered = inventoryMobileRows(group, 'region');
const pages = Array.from({ length: 4 }, (_, index) => inventoryPage(ordered, index));
assert.deepEqual(pages.map(page => page.rows.length), [6, 6, 6, 1]);
assert.deepEqual(pages.flatMap(page => page.rows), rows);
assert.equal(inventoryPage(rows.slice(0, 2), 3).page, 0);
assert.deepEqual(inventoryPage([], 1), { page: 0, pageCount: 1, start: 0, rows: [] });
});
test('inventory status hints show nonzero subset counts and hide zeros', () => {
const row = { ...filters, batch: '', quantity: 9, pending: 2, abnormal: 1 };
const html = renderToStaticMarkup(createElement(InventoryStatusCounts, { rows: [row] }));
assert.match(html, /其中待交车 <strong>2<\/strong> 台/);
assert.match(html, /其中异动 <strong>1<\/strong> 台/);
assert.equal(renderToStaticMarkup(createElement(InventoryStatusCounts, { rows: [{ ...row, pending: 0, abnormal: 0 }] })), '');
});
test('filtered inventory totals retain all filters in the vehicle request', () => {
const request = buildVehicleModalRequest(inventorySelection(filters, '筛选结果'), '公司甲');
assert.deepEqual(request, { kind: 'vehicles', params: {
@@ -44,7 +159,7 @@ test('model tags separate type and qualifiers without inventing a batch', () =>
{ kind: 'detail', label: '型号说明', value: '交投' },
]);
assert.deepEqual(inventoryModelTags({ type: '18T', model: '飞驰18T(白车)', batch: '2026-01' }).map(tag => tag.value), ['18T', '白车', '2026-01']);
assert.deepEqual(inventoryModelTags({ type: '挂车', model: '挂车', batch: '挂车' }).map(tag => tag.value), ['挂车']);
assert.deepEqual(inventoryModelTags({ type: '挂车', model: '挂车', batch: '挂车' }).map(tag => tag.value), ['其他', '挂车']);
assert.deepEqual(inventoryModelTags({ type: '', model: '未知型号(未闭合', batch: '' }).map(tag => tag.value), ['未知型号(未闭合']);
});
@@ -107,3 +222,29 @@ test('model grouping merges locations but keeps different brands, colors and bat
assert.equal(inventorySelection(row, '明细').location, row.city);
}
});
test('inventory other includes trailers in groups, filters and drilldown while retaining exact row types', () => {
const trailers = { ...filters, brand: '明威', type: '挂车', model: '挂车', batch: '挂车', quantity: 3, pending: 1 };
const other = { ...filters, brand: '其他', type: '其他', model: '氢能叉车', batch: '', quantity: 2, abnormal: 1 };
const standard = { ...filters, type: '18T', batch: '', quantity: 10 };
const filter = { region: '', city: '', brand: '', type: '其他', model: '' };
const view = deriveInventoryView([trailers, other, standard], filter, '其他');
assert.deepEqual(view.filtered, [trailers, other]);
assert.deepEqual(view.types, ['18T', '其他']);
assert.deepEqual(view.modelsForType, ['挂车', '氢能叉车']);
assert.deepEqual(Object.keys(view.byModel), ['其他']);
const group = inventoryGroups(view.byModel, 'default', 'model')[0];
assert.equal(group.quantity, 5);
assert.equal(group.rows.reduce((sum, row) => sum + (row.pending || 0), 0), 1);
assert.equal(group.rows.reduce((sum, row) => sum + (row.abnormal || 0), 0), 1);
let selected: VehicleModalSelection | null = null;
const select = (value: VehicleModalSelection | null | ((previous: VehicleModalSelection | null) => VehicleModalSelection | null)) => { selected = typeof value === 'function' ? value(selected) : value; };
selectInventoryGroup(group, { tab: 'model', filters: filter, select });
assert.equal((selected as VehicleModalSelection | null)?.vehicleType, '库存其他');
assert.equal(inventorySelection(filter, '筛选后库存').vehicleType, '库存其他');
selectInventoryRow(trailers, select);
assert.equal((selected as VehicleModalSelection | null)?.vehicleType, '挂车');
assert.equal((selected as VehicleModalSelection | null)?.model, '挂车');
selectInventoryRow(other, select);
assert.equal((selected as VehicleModalSelection | null)?.vehicleType, '其他');
});
@@ -1,5 +1,6 @@
import type { InventoryFilters, VehicleModalSelection } from '../../model';
import { inventoryTypeLabel, type InventoryFilters, type VehicleModalSelection } from '../../model';
import type { RegionalInventoryStats } from '../../types';
import { inventoryKey } from '../../../../shared/assets/inventory';
const BRAND_ORDER = ['现代', '帕力安', '宇通', '飞驰', '跃进'];
const normalizeBrand = (brand: string) => brand.trim().replace(/牌$/, '');
@@ -21,7 +22,7 @@ export function inventoryTotal(rows: RegionalInventoryStats[]) {
/** Decompose display text only; never change the model used for drilldown. */
export function inventoryModelTags(row: Pick<RegionalInventoryStats, 'type' | 'model' | 'batch'> & Partial<Pick<RegionalInventoryStats, 'brand'>>) {
const tags: { kind: 'type' | 'model' | 'detail' | 'batch'; label: string; value: string }[] = [];
const type = row.type.trim();
const type = inventoryTypeLabel(row.type.trim());
const model = row.model.trim();
const details = Array.from(model.matchAll(/[((]([^()()]+)[))]/g), match => match[1].trim()).filter(Boolean);
const base = model.replace(/[((]([^()()]+)[))]/g, '').trim();
@@ -47,11 +48,21 @@ export function inventorySelection(filters: InventoryFilters, title: string, ove
return {
batch: 'All', model: filters.model || 'All',
location: filters.city || filters.region || 'All',
brand: filters.brand || undefined, vehicleType: filters.type || undefined,
brand: filters.brand || undefined, vehicleType: inventoryTypeLabel(filters.type) === '其他' ? '库存其他' : filters.type || undefined,
category: 'Inventory', source: 'asset', title, ...overrides,
};
}
export function inventorySubtotalSelection(rows: RegionalInventoryStats[], label: string): VehicleModalSelection {
const common = (key: 'region' | 'city' | 'brand' | 'type') => rows.length && rows.every(row => row[key] === rows[0][key]) ? rows[0][key] : '';
const scope = [...new Set([common('region'), common('city'), common('brand'), common('type'), label].filter(Boolean))].join(' · ');
return {
batch: 'All', model: 'All', location: 'All', source: 'asset', category: 'Inventory',
inventoryKeys: [...new Set(rows.map(inventoryKey))],
title: `库存统计 · ${scope}`, scopeLabel: `${scope} · 合计 ${inventoryTotal(rows)} 台`,
};
}
export interface InventoryDisplayGroup {
name: string;
sections: [string, RegionalInventoryStats[]][];
@@ -59,6 +70,19 @@ export interface InventoryDisplayGroup {
quantity: number;
}
/** Keep each category's regions contiguous so only the first city renders its region cell. */
export function inventoryRegionRows(rows: RegionalInventoryStats[]) {
const regions = new Map<string, RegionalInventoryStats[]>();
for (const row of rows) {
const records = regions.get(row.region) || [];
records.push(row);
regions.set(row.region, records);
}
return [...regions.values()].flatMap(records => records.map((row, index) => ({
row, regionSpan: index === 0 ? records.length : 0,
})));
}
/** Group presentation only: keep each original record for quantity and drilldown. */
export function inventoryBrandGroups(rows: RegionalInventoryStats[], groupBy: 'type' | 'model') {
const brands = new Map<string, { brand: string; rows: RegionalInventoryStats[]; variants: Map<string, RegionalInventoryStats[]> }>();
@@ -70,7 +94,7 @@ export function inventoryBrandGroups(rows: RegionalInventoryStats[], groupBy: 't
brands.set(key, brand);
}
brand.rows.push(row);
const variantKey = groupBy === 'type' ? row.type : JSON.stringify([row.type, row.model, row.batch]);
const variantKey = groupBy === 'type' ? inventoryTypeLabel(row.type) : JSON.stringify([row.type, row.model, row.batch]);
const variant = brand.variants.get(variantKey) || [];
variant.push(row);
brand.variants.set(variantKey, variant);
@@ -79,6 +103,67 @@ export function inventoryBrandGroups(rows: RegionalInventoryStats[], groupBy: 't
.map(brand => ({ ...brand, variants: [...brand.variants.values()] }));
}
export function inventoryMobileRows(group: InventoryDisplayGroup, tab: 'region' | 'model') {
const sections = tab === 'region' ? group.sections : [[group.name, group.rows] as const];
return sections.flatMap(([, rows]) => inventoryBrandGroups(rows, tab === 'region' ? 'type' : 'model')
.flatMap(brand => brand.variants.flatMap(variants => tab === 'model'
? inventoryRegionRows(variants).map(({ row }) => row) : variants)));
}
/** Each level is a subtotal within its parent, across all pages of the filtered list. */
export function inventoryHierarchy(row: RegionalInventoryStats, rows: RegionalInventoryStats[], tab: 'region' | 'model') {
const cityRows = rows.filter(item => item.region === row.region && item.city === row.city);
const brandRows = (tab === 'region' ? cityRows : rows).filter(item => normalizeBrand(item.brand) === normalizeBrand(row.brand));
const variantRows = brandRows.filter(item => tab === 'region'
? inventoryTypeLabel(item.type) === inventoryTypeLabel(row.type)
: item.type === row.type && item.model === row.model && item.batch === row.batch);
const levels = tab === 'region'
? [{ label: row.city || '未分配', rows: cityRows }, { label: row.brand || '未分配品牌', rows: brandRows }, { label: inventoryTypeLabel(row.type) || '未分类', rows: variantRows }]
: [{ label: row.brand || '未分配品牌', rows: brandRows }, { label: inventoryModelTags(row).filter(tag => tag.kind !== 'type').map(tag => tag.value).join(' · ') || '未分类', rows: variantRows }, { label: row.region || '未分配', rows: variantRows.filter(item => item.region === row.region) }];
return levels.map(level => ({ ...level, quantity: inventoryTotal(level.rows) }));
}
export type InventoryMobileDimension = 'region' | 'city' | 'type' | 'variant';
/** One dimension per screen. Every child retains exact records from its parent. */
export function inventoryMobileGroups(rows: RegionalInventoryStats[], dimension: InventoryMobileDimension) {
const groups = new Map<string, { key: string; label: string; rows: RegionalInventoryStats[] }>();
for (const row of rows) {
const key = dimension === 'variant'
? JSON.stringify([normalizeBrand(row.brand), row.type, row.model, row.batch])
: dimension === 'type' ? inventoryTypeLabel(row.type) : row[dimension];
const qualifiers = dimension === 'variant' ? inventoryModelTags(row).filter(tag => tag.kind !== 'type').map(tag => tag.value).join(' · ') : '';
const label = dimension === 'variant'
? [row.brand || '未分配品牌', qualifiers || row.model || '未分配型号'].join(' · ')
: key || (dimension === 'type' ? '未分类车型' : `未分配${dimension === 'city' ? '城市' : '区域'}`);
const group = groups.get(key) || { key, label, rows: [] };
group.rows.push(row);
groups.set(key, group);
}
return [...groups.values()].map(group => ({ ...group, quantity: inventoryTotal(group.rows) }));
}
export function inventoryMobileDrilldown(rows: RegionalInventoryStats[], tab: 'region' | 'model', path: string[]) {
const dimensions: InventoryMobileDimension[] = tab === 'region' ? ['city', 'type', 'variant'] : ['region', 'city', 'variant'];
const parents: ReturnType<typeof inventoryMobileGroups> = [];
let currentRows = rows;
for (const key of path.slice(0, dimensions.length - 1)) {
const parent = inventoryMobileGroups(currentRows, dimensions[parents.length]).find(group => group.key === key);
if (!parent) break;
parents.push(parent);
currentRows = parent.rows;
}
const dimension = dimensions[parents.length];
return { parents, rows: currentRows, dimension, groups: inventoryMobileGroups(currentRows, dimension) };
}
export function inventoryPage<T>(rows: T[], requestedPage: number) {
const pageSize = 6;
const pageCount = Math.max(1, Math.ceil(rows.length / pageSize));
const page = Math.max(0, Math.min(requestedPage, pageCount - 1));
return { page, pageCount, start: page * pageSize, rows: rows.slice(page * pageSize, (page + 1) * pageSize) };
}
export function inventoryGroups(source: Record<string, Record<string, RegionalInventoryStats[]>>, sort: 'default' | 'quantity', groupBy: 'region' | 'model' = 'region'): InventoryDisplayGroup[] {
const groups = Object.entries(source).map(([name, children]) => {
const sections: InventoryDisplayGroup['sections'] = Object.entries(children).map(([section, records]) => [
@@ -7,14 +7,24 @@
--inventory-accent: #2458cf;
--inventory-accent-soft: #eff5ff;
--inventory-warning: #a34d0a;
--inventory-warning-soft: #fff7e8;
--inventory-primary: #224baa;
--inventory-primary-hover: #193d94;
--inventory-accent-line: #c8d9f6;
--inventory-group: #f2f6fc;
--inventory-detail: #675086;
--inventory-detail-soft: #f6f2fb;
--inventory-detail-line: #e5dcf0;
--inventory-model: #226b69;
--inventory-model-soft: #f0f8f7;
min-width: 0;
margin-bottom: 24px;
overflow: hidden;
border: 1px solid var(--inventory-line);
border-radius: 16px;
border-radius: 14px;
background: var(--inventory-bg);
color: var(--inventory-text);
box-shadow: 0 2px 6px rgb(15 23 42 / 3%);
box-shadow: 0 4px 18px rgb(23 36 59 / 4%);
font-size: 12px;
line-height: 1.5;
}
@@ -23,9 +33,9 @@
.inventory-statistics :is(button, select):focus-visible { outline: 3px solid var(--inventory-accent); outline-offset: 3px; }
.inventory-statistics button:active:not(:disabled) { opacity: .75; }
.inventory-heading, .inventory-toolbar, .inventory-filter-heading, .inventory-filter-actions, .inventory-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.inventory-heading { padding: 10px 16px; }
.inventory-heading { padding: 12px 16px 10px; }
.inventory-title { display: flex; align-items: center; gap: 12px; min-width: 0; }
.inventory-title-icon { display: grid; place-items: center; flex-shrink: 0; width: 30px; height: 30px; border-radius: 8px; color: var(--inventory-accent); background: var(--inventory-accent-soft); }
.inventory-title-icon { display: grid; place-items: center; flex-shrink: 0; width: 32px; height: 32px; border-radius: 9px; color: var(--inventory-accent); background: var(--inventory-accent-soft); border: 1px solid var(--inventory-accent-line); }
.inventory-title h2 { font-size: 15px; font-weight: 700; }
.inventory-title p { margin-top: 0; font-size: 12px; color: var(--inventory-muted); }
.inventory-control, .inventory-text-button, .inventory-icon-button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 32px; border-radius: 6px; font-weight: 500; white-space: nowrap; }
@@ -36,19 +46,25 @@
.inventory-text-button:hover { color: var(--inventory-accent); background: var(--inventory-accent-soft); }
.inventory-icon-button { width: 32px; color: var(--inventory-muted); }
.inventory-filter-count { display: grid; place-items: center; height: 20px; min-width: 20px; border-radius: 5px; background: var(--inventory-accent-soft); color: var(--inventory-accent); font-size: 12px; }
.inventory-metrics { display: grid; grid-template-columns: 1.25fr 1fr 1fr; margin: 0 16px; border: 1px solid var(--inventory-line); border-radius: 8px; overflow: hidden; }
.inventory-metric { position: relative; padding: 8px 12px; text-align: left; min-width: 0; background: var(--inventory-bg); }
.inventory-metric + .inventory-metric { border-left: 1px solid var(--inventory-line); }
.inventory-metrics { display: grid; grid-template-columns: 1.25fr 1fr 1fr; gap: 8px; margin: 0 16px; }
.inventory-metric { position: relative; padding: 8px 12px; text-align: left; min-width: 0; border: 1px solid var(--inventory-line); border-radius: 9px; background: var(--inventory-soft); }
.inventory-metric > span:first-child { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-size: 12px; color: var(--inventory-muted); }
.inventory-metric strong { display: block; font-size: 22px; font-weight: 650; letter-spacing: -.03em; line-height: 1.25; margin: 2px 0 0; font-variant-numeric: tabular-nums; }
.inventory-metric small { color: var(--inventory-muted); font-size: 12px; font-weight: 400; margin-left: 6px; }
.inventory-summary-note { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 4px 12px; padding: 4px 16px 8px; font-size: 12px; color: var(--inventory-muted); }
.inventory-metric-primary { background: var(--inventory-accent-soft); }
.inventory-metric-primary strong { color: var(--inventory-accent); }
.inventory-summary-note { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 4px 12px; padding: 6px 16px 10px; font-size: 12px; color: var(--inventory-muted); }
.inventory-metric-primary { background: var(--inventory-primary); border-color: var(--inventory-primary); }
.inventory-metric-primary > span:first-child, .inventory-metric-primary strong, .inventory-metric-primary small { color: #fff; }
.inventory-metric-primary > span:first-child > svg { padding: 2px; width: 20px; height: 20px; border-radius: 5px; background: rgb(255 255 255 / 14%); }
.inventory-metric-pending:not(:disabled) { background: var(--inventory-accent-soft); border-color: var(--inventory-accent-line); }
.inventory-metric-pending:not(:disabled) strong { color: var(--inventory-accent); }
.inventory-metric-warning:not(:disabled) { background: var(--inventory-warning-soft); }
.inventory-metric-warning:not(:disabled) strong { color: var(--inventory-warning); }
.inventory-metric:disabled:not(.inventory-metric-primary) strong { color: var(--inventory-muted); }
.inventory-metric:disabled > span > svg { visibility: hidden; }
.inventory-metric:not(:disabled):hover { background: var(--inventory-accent-soft); }
.inventory-metric:not(:disabled):hover { border-color: var(--inventory-accent); box-shadow: 0 2px 6px rgb(23 36 59 / 8%); }
.inventory-metric-primary:not(:disabled):hover { background: var(--inventory-primary-hover); }
.inventory-metric:focus-visible { outline-offset: -4px !important; }
.inventory-metric-primary:focus-visible { outline-color: white !important; }
.inventory-filters { padding: 8px 16px 12px; background: var(--inventory-soft); border-block: 1px solid var(--inventory-line); }
.inventory-filter-heading { margin-bottom: 8px; }
.inventory-filter-fields { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 14px; }
@@ -61,35 +77,40 @@
.inventory-active-filters button { display: inline-flex; align-items: center; gap: 8px; padding: 4px 10px; min-height: 36px; border-radius: 6px; background: var(--inventory-accent-soft); color: var(--inventory-accent); }
.inventory-active-filters .inventory-clear { background: transparent; }
.inventory-toolbar { padding: 8px 16px; border-top: 1px solid var(--inventory-line); }
.inventory-view-switch { display: inline-flex; padding: 3px; background: var(--inventory-soft); border: 1px solid var(--inventory-line); border-radius: 10px; }
.inventory-view-switch { display: inline-flex; padding: 3px; background: var(--inventory-group); border: 1px solid var(--inventory-line); border-radius: 8px; }
.inventory-view-switch button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 28px; padding: 4px 12px; border-radius: 6px; color: var(--inventory-muted); font-size: 12px; }
.inventory-view-switch button[aria-pressed="true"] { background: var(--inventory-bg); color: var(--inventory-accent); box-shadow: 0 1px 4px rgb(15 23 42 / 10%); font-weight: 600; }
.inventory-view-switch button[aria-pressed="true"] { background: var(--inventory-bg); color: var(--inventory-accent); box-shadow: 0 1px 3px rgb(15 23 42 / 10%), inset 0 0 0 1px var(--inventory-accent-line); font-weight: 600; }
.inventory-toolbar-actions { display: flex; align-items: center; gap: 12px; }
.inventory-sort select { min-width: 144px; padding-right: 28px; font-size: 12px; }
.inventory-table { width: 100%; border-collapse: collapse; table-layout: fixed; }
.inventory-table thead { background: var(--inventory-soft); color: var(--inventory-muted); font-size: 12px; }
.inventory-table th, .inventory-table td { padding: 4px 16px; border-bottom: 1px solid var(--inventory-line); text-align: left; overflow-wrap: anywhere; }
.inventory-table thead th { font-weight: 500; padding-block: 8px; }
.inventory-table thead th { font-weight: 600; padding-block: 8px; }
.inventory-table[data-group-by="model"] thead th:first-child { width: 14%; }
.inventory-table[data-group-by="model"] thead th:nth-child(2) { width: 30%; }
.inventory-table[data-group-by="model"] thead th:nth-child(3) { width: 26%; }
.inventory-table[data-group-by="model"] thead th:nth-child(3) { width: 10%; }
.inventory-table[data-group-by="model"] thead th:nth-child(4) { width: 16%; }
.inventory-table[data-group-by="region"] thead th:first-child { width: 12%; }
.inventory-table[data-group-by="region"] thead th:nth-child(2) { width: 12%; }
.inventory-table[data-group-by="region"] thead th:nth-child(3) { width: 16%; }
.inventory-table[data-group-by="region"] thead th:nth-child(4) { width: 30%; }
.inventory-table .inventory-number-column { text-align: right; width: 14%; }
.inventory-table .inventory-share-column { width: 16%; }
.inventory-group-row { background: var(--inventory-soft); }
.inventory-group-row { background: var(--inventory-group); }
.inventory-group-row[data-expanded="true"] { background: var(--inventory-accent-soft); }
.inventory-group-row[data-expanded="true"] > th { box-shadow: inset 3px 0 var(--inventory-accent); }
.inventory-group-row th { font-weight: 400; }
.inventory-group-heading { display: flex; align-items: center; gap: 20px; flex-wrap: wrap; }
.inventory-expand { display: flex; align-items: center; gap: 8px; min-height: 30px; min-width: 0; text-align: left; }
.inventory-expand > svg { color: var(--inventory-muted); flex-shrink: 0; }
.inventory-expand[aria-expanded="true"] > svg { transform: rotate(90deg); }
.inventory-expand strong { font-size: 12px; font-weight: 650; }
.inventory-expand > span { font-size: 12px; font-weight: 400; color: var(--inventory-muted); }
.inventory-quantity { display: inline-flex; align-items: center; justify-content: flex-end; gap: 8px; min-width: 44px; min-height: 30px; border-radius: 6px; color: var(--inventory-accent); font-size: 13px; font-weight: 650; font-variant-numeric: tabular-nums; }
.inventory-expand > span { font-size: 12px; font-weight: 400; color: var(--inventory-muted); padding: 1px 6px; border: 1px solid var(--inventory-line); border-radius: 5px; background: var(--inventory-bg); }
.inventory-quantity { display: inline-flex; align-items: center; justify-content: flex-end; gap: 6px; padding: 0 6px; min-width: 52px; min-height: 30px; border-radius: 6px; color: var(--inventory-accent); font-size: 13px; font-weight: 650; font-variant-numeric: tabular-nums; }
.inventory-quantity svg { opacity: .7; }
.inventory-quantity:not(:disabled):hover { text-decoration: underline; text-underline-offset: 4px; }
.inventory-quantity:not(:disabled):hover, .inventory-quantity:focus-visible { background: var(--inventory-accent-soft); text-decoration: underline; text-underline-offset: 4px; }
.inventory-group-row .inventory-quantity { background: var(--inventory-bg); box-shadow: inset 0 0 0 1px var(--inventory-accent-line); }
.inventory-group-row .inventory-quantity:not(:disabled):hover { background: var(--inventory-primary); color: white; }
.inventory-quantity:disabled { color: var(--inventory-muted); }
.inventory-quantity:disabled svg { visibility: hidden; }
.inventory-share { display: flex; align-items: center; justify-content: flex-end; gap: 10px; color: var(--inventory-muted); font-size: 12px; font-variant-numeric: tabular-nums; }
@@ -97,19 +118,32 @@
.inventory-share-track { flex: 1; height: 5px; border-radius: 4px; background: var(--inventory-line); overflow: hidden; }
.inventory-share-track > span { display: block; height: 100%; border-radius: inherit; background: var(--inventory-accent); }
.inventory-detail-row { font-size: 12px; }
.inventory-detail-row:hover { background: var(--inventory-accent-soft); }
.inventory-detail-row:hover, .inventory-detail-row:focus-within { background: var(--inventory-accent-soft); }
.inventory-detail-row .inventory-merged-cell { font-weight: 500; padding: 10px 16px; vertical-align: top; background: var(--inventory-bg); border-right: 1px solid var(--inventory-line); }
.inventory-detail-row .inventory-city-cell { padding-left: 36px; }
.inventory-detail-row .inventory-city-cell { padding-left: 36px; background: var(--inventory-soft); font-weight: 600; }
.inventory-detail-row .inventory-region-cell { color: var(--inventory-muted); }
.inventory-subtotal { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; margin-top: 4px; color: var(--inventory-muted); font-size: 12px; font-weight: 400; }
.inventory-subtotal > span { white-space: nowrap; }
.inventory-subtotal strong { color: var(--inventory-text); font-weight: 650; font-variant-numeric: tabular-nums; }
.inventory-subtotal-button { display: inline-flex; align-items: center; gap: 4px; min-height: 24px; padding: 0 5px; border: 1px solid var(--inventory-accent-line); border-radius: 5px; background: var(--inventory-accent-soft); color: var(--inventory-accent); white-space: nowrap; }
.inventory-subtotal-button strong { color: inherit; }
.inventory-subtotal-button:hover { border-color: var(--inventory-accent); text-decoration: underline; text-underline-offset: 3px; }
.inventory-subtotal [data-testid="inventory-status-counts"] { flex-direction: column; align-items: flex-start; }
.inventory-subtotal-inline { display: inline-flex; margin: 0 0 0 8px; vertical-align: middle; }
.inventory-detail-group + .inventory-detail-group { border-top: 1px solid var(--inventory-line); }
.inventory-place-region { margin-right: 8px; color: var(--inventory-muted); }
.inventory-model-tags { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 4px; max-width: 100%; vertical-align: middle; }
.inventory-tag { display: inline-block; max-width: 100%; padding: 1px 6px; border: 1px solid var(--inventory-line); border-radius: 4px; background: var(--inventory-soft); color: var(--inventory-text); font-size: 12px; font-weight: 400; line-height: 18px; overflow-wrap: anywhere; }
.inventory-tag-type { background: var(--inventory-accent-soft); border-color: #dbe6fb; color: var(--inventory-accent); }
.inventory-tag-detail, .inventory-tag-batch { background: #f5f3ff; border-color: #e7e1f6; color: #69518b; }
.inventory-tag { display: inline-block; max-width: 100%; padding: 1px 6px; border: 1px solid var(--inventory-line); border-radius: 5px; background: var(--inventory-soft); color: var(--inventory-text); font-size: 12px; font-weight: 500; line-height: 18px; overflow-wrap: anywhere; }
.inventory-tag-type { background: var(--inventory-accent-soft); border-color: var(--inventory-accent-line); color: var(--inventory-accent); }
.inventory-tag-model { background: var(--inventory-model-soft); color: var(--inventory-model); }
.inventory-tag-detail, .inventory-tag-batch { background: var(--inventory-detail-soft); border-color: var(--inventory-detail-line); color: var(--inventory-detail); }
.inventory-detail-row .inventory-share-track > span { opacity: .45; }
.inventory-statistics [data-testid="inventory-status-counts"] { font-size: 12px; }
.inventory-statistics [data-testid="inventory-status-counts"] { font-size: 12px; gap: 4px; }
.inventory-statistics [data-testid="inventory-status-counts"] > span { border-radius: 4px; padding: 1px 5px; white-space: nowrap; }
.inventory-statistics .inventory-status-abnormal { color: var(--inventory-warning); background: var(--inventory-warning-soft); }
.inventory-statistics .inventory-status-pending { color: var(--inventory-accent); background: var(--inventory-accent-soft); }
.inventory-number-column [data-testid="inventory-status-counts"] { display: flex; justify-content: flex-end; }
.inventory-footer { flex-wrap: wrap; padding: 8px 16px; font-size: 12px; color: var(--inventory-muted); }
.inventory-footer { flex-wrap: wrap; padding: 8px 16px; font-size: 12px; color: var(--inventory-muted); background: var(--inventory-soft); }
.inventory-empty { display: grid; justify-items: center; gap: 12px; padding: 48px 20px; text-align: center; color: var(--inventory-muted); border-block: 1px solid var(--inventory-line); }
.inventory-empty h3 { color: var(--inventory-text); font-weight: 600; font-size: 16px; }
.inventory-empty p { font-size: 13px; }
@@ -118,46 +152,104 @@
.inventory-statistics button, .inventory-expand > svg { transition: background-color 150ms ease, color 150ms ease, transform 150ms ease; }
}
@media (max-width: 1023px) {
/* Match the shared SurfaceCard / SegmentedNav palette on mobile. */
.inventory-statistics {
--inventory-text: #0f172a;
--inventory-muted: #64748b;
--inventory-line: #e2e8f0;
--inventory-accent: #1d4ed8;
--inventory-accent-soft: #eff6ff;
--inventory-accent-line: #dbeafe;
--inventory-group: #f8fafc;
--inventory-detail: #475569;
--inventory-detail-soft: #f8fafc;
--inventory-detail-line: #e2e8f0;
--inventory-model: #475569;
--inventory-model-soft: #f8fafc;
border-color: #f1f5f9;
border-radius: 16px;
box-shadow: 0 1px 3px rgb(15 23 42 / 8%);
}
.inventory-title-icon { border: 0; background: var(--inventory-accent-soft); }
.inventory-metric { background: var(--inventory-soft); border-color: #f1f5f9; border-radius: 12px; }
.inventory-metric-primary { background: var(--inventory-accent-soft); border-color: var(--inventory-accent-line); }
.inventory-metric-primary > span:first-child, .inventory-metric-primary small { color: var(--inventory-muted); }
.inventory-metric-primary strong { color: var(--inventory-accent); }
.inventory-metric-primary > span:first-child > svg { background: transparent; color: var(--inventory-accent); }
.inventory-metric-primary:not(:disabled):hover { background: #dbeafe; }
.inventory-metric-primary:focus-visible { outline-color: var(--inventory-accent) !important; }
.inventory-view-switch { border-color: #f1f5f9; background: var(--inventory-soft); border-radius: 12px; }
.inventory-view-switch button { border-radius: 9px; min-height: 44px; }
.inventory-view-switch button[aria-pressed="true"] { background: var(--inventory-bg); box-shadow: 0 1px 3px rgb(15 23 42 / 8%); }
.inventory-statistics select { border-radius: 10px; }
.inventory-statistics :is(.inventory-control, .inventory-text-button, .inventory-icon-button) { min-height: 44px; border-radius: 10px; }
.inventory-icon-button { min-width: 44px; }
.inventory-desktop { display: none; }
.inventory-expand-all { display: none; }
.inventory-mobile { display: grid; gap: 8px; padding: 0 16px 8px; }
.inventory-mobile-group { border: 1px solid var(--inventory-line); border-radius: 10px; overflow: hidden; }
.inventory-mobile-group-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 4px 12px 0; }
.inventory-mobile-hint { color: var(--inventory-muted); font-size: 12px; }
.inventory-mobile-group { border: 1px solid var(--inventory-line); border-radius: 12px; overflow: hidden; background: var(--inventory-bg); }
.inventory-mobile-group[data-expanded="true"] { border-color: var(--inventory-accent-line); }
.inventory-mobile-group-heading > .inventory-quantity { padding-inline: 8px; min-width: 60px; font-size: 16px; }
.inventory-mobile-group-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 4px 10px 0; }
.inventory-mobile-group-heading .inventory-expand > span { padding: 0; border: 0; background: transparent; }
.inventory-mobile-group-heading .inventory-expand > .inventory-mobile-group-icon { display: grid; place-items: center; width: 28px; height: 28px; flex-shrink: 0; border-radius: 9px; background: var(--inventory-soft); color: var(--inventory-muted); }
.inventory-mobile-group[data-expanded="true"] .inventory-expand > .inventory-mobile-group-icon { background: var(--inventory-accent-soft); color: var(--inventory-accent); }
.inventory-mobile-group-heading .inventory-expand strong { font-size: 14px; }
.inventory-mobile-group-meta { display: flex; align-items: center; gap: 12px; padding: 0 12px 8px; font-size: 12px; color: var(--inventory-muted); }
.inventory-mobile-group-meta .inventory-share { flex: 1; }
.inventory-mobile-status { padding: 0 12px 6px; }
.inventory-mobile-details { border-top: 1px solid var(--inventory-line); padding: 0 12px 4px; }
.inventory-mobile-details h3 { padding: 8px 0 4px; font-size: 12px; font-weight: 600; color: var(--inventory-muted); overflow-wrap: anywhere; }
.inventory-mobile-brand { padding-block: 6px; border-top: 1px solid var(--inventory-line); }
.inventory-mobile-brand h4 { font-size: 12px; font-weight: 600; margin-bottom: 4px; }
.inventory-mobile-variant { padding-left: 10px; border-left: 2px solid var(--inventory-line); }
.inventory-mobile-variant + .inventory-mobile-variant { margin-top: 8px; }
.inventory-mobile-variant-heading { padding-top: 2px; }
.inventory-mobile-row { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 4px 8px; padding: 4px 0; border-bottom: 1px solid var(--inventory-line); }
.inventory-mobile-row:last-child { border-bottom: 0; }
.inventory-mobile-row > div:first-child { flex: 1; min-width: 0; }
.inventory-mobile-row strong { font-size: 12px; font-weight: 500; overflow-wrap: anywhere; }
.inventory-mobile-row p { font-size: 12px; color: var(--inventory-muted); margin-top: 2px; }
.inventory-mobile-row .inventory-mobile-status { width: 100%; padding: 0; }
.inventory-mobile-detail-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding-block: 6px; color: var(--inventory-muted); }
.inventory-mobile-detail-toolbar h3 { min-width: 0; color: var(--inventory-text); font-size: 13px; font-weight: 650; }
.inventory-mobile-detail-toolbar h3 > span { color: var(--inventory-muted); font-size: 12px; font-weight: 400; }
.inventory-mobile-detail-toolbar h3:focus-visible { outline: 2px solid var(--inventory-accent); outline-offset: 3px; }
.inventory-mobile-detail-toolbar .inventory-text-button { padding-inline: 0; }
.inventory-mobile-breadcrumb { display: flex; flex-wrap: wrap; align-items: center; gap: 0 6px; color: var(--inventory-muted); font-size: 12px; padding-top: 4px; }
.inventory-mobile-breadcrumb button { min-height: 44px; min-width: 44px; color: var(--inventory-accent); text-align: left; }
.inventory-mobile-breadcrumb [aria-current] { font-weight: 600; color: var(--inventory-text); }
.inventory-mobile-list-heading, .inventory-mobile-scope-total { display: flex; justify-content: space-between; gap: 8px; padding: 6px 8px; background: var(--inventory-soft); color: var(--inventory-muted); font-size: 12px; border-radius: 6px; }
.inventory-mobile-scope-total { margin-block: 4px; }
.inventory-mobile-scope-total strong { font-variant-numeric: tabular-nums; color: var(--inventory-text); font-weight: 600; }
.inventory-mobile-records { list-style: none; margin: 0; padding: 0; }
.inventory-mobile-records li + li { border-top: 1px solid var(--inventory-line); }
.inventory-mobile-drill-row { display: flex; flex-wrap: wrap; align-items: center; gap: 0 8px; padding-block: 2px; }
.inventory-mobile-drill-label { display: flex; flex: 1; align-items: center; gap: 8px; min-width: 0; min-height: 44px; padding: 6px 4px; border-radius: 6px; text-align: left; font-size: 13px; font-weight: 500; overflow-wrap: anywhere; }
.inventory-mobile-drill-label > svg { flex-shrink: 0; color: var(--inventory-muted); }
.inventory-mobile-drill-label:hover { background: var(--inventory-soft); }
.inventory-mobile-drill-row .inventory-mobile-status { width: 100%; padding: 0 4px 4px; }
.inventory-mobile-pagination { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding-block: 4px; border-top: 1px solid var(--inventory-line); color: var(--inventory-muted); }
.inventory-mobile-pagination button { display: inline-flex; align-items: center; justify-content: center; gap: 4px; min-height: 44px; min-width: 80px; border-radius: 6px; color: var(--inventory-accent); }
.inventory-mobile-pagination button:disabled { color: var(--inventory-muted); opacity: .5; }
.inventory-mobile-pagination button:not(:disabled):hover { background: var(--inventory-accent-soft); }
.inventory-mobile-pagination > span { font-variant-numeric: tabular-nums; font-weight: 600; }
.inventory-mobile-pagination button:not(:disabled) { background: var(--inventory-accent-soft); }
.inventory-mobile :is(.inventory-quantity, .inventory-expand) { min-height: 44px; }
.inventory-mobile-group-heading .inventory-expand { flex: 1; }
.inventory-filter-fields { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 639px) {
.inventory-heading { padding: 10px 12px; align-items: flex-start; gap: 10px; }
.inventory-title { gap: 8px; }
.inventory-title-icon { display: none; }
.inventory-title-icon { width: 28px; height: 28px; border-radius: 9px; }
.inventory-title-icon svg { width: 16px; height: 16px; }
.inventory-title h2 { font-size: 15px; }
.inventory-title p { max-width: 24ch; }
.inventory-title p { margin-top: 2px; }
.inventory-heading .inventory-control { padding: 8px 10px; }
.inventory-heading .inventory-control > svg:last-child { display: none; }
.inventory-metrics { margin: 0 12px; grid-template-columns: repeat(3, minmax(0, 1fr)); }
.inventory-metrics { margin: 0 12px; gap: 6px; grid-template-columns: repeat(3, minmax(0, 1fr)); }
.inventory-metric { padding: 8px; }
.inventory-metric strong { font-size: 20px; }
.inventory-metric strong { font-size: 24px; font-weight: 700; }
.inventory-metric > span:first-child { font-size: 12px; }
.inventory-toolbar { align-items: stretch; flex-direction: column; gap: 6px; padding: 8px 12px; }
.inventory-view-switch button { flex: 1; min-height: 36px; }
.inventory-toolbar { align-items: center; gap: 8px; padding: 8px 12px; }
.inventory-view-switch { flex: 1; min-width: 0; }
.inventory-view-switch button { flex: 1; min-height: 44px; padding-inline: 6px; white-space: nowrap; }
.inventory-toolbar .inventory-sort select { min-width: 0; width: 112px; min-height: 44px; padding: 4px 6px; font-size: 16px; border-color: transparent; background: var(--inventory-soft); }
.inventory-heading .inventory-control { min-height: 44px; }
.inventory-toolbar-actions { justify-content: space-between; gap: 8px; }
.inventory-toolbar-actions .inventory-text-button { font-size: 12px; }
.inventory-mobile { padding: 0 12px 8px; }
.inventory-mobile-group-heading { padding-inline: 12px; }
.inventory-mobile-group-heading { padding-inline: 10px; }
.inventory-expand { gap: 6px; }
.inventory-expand strong { font-size: 12px; }
.inventory-expand > span { font-size: 12px; }
+11 -7
View File
@@ -6,7 +6,8 @@ import type {
VehicleListItem,
} from './types';
const INVENTORY_TYPE_ORDER = ['4.5T普货', '4.5T冷链', '18T', '49T', '挂车', '其他'];
const INVENTORY_TYPE_ORDER = ['4.5T普货', '4.5T冷链', '18T', '49T', '其他'];
export const inventoryTypeLabel = (type: string) => type === '挂车' ? '其他' : type;
const CHINESE_NUMBER_ORDER: Record<string, number> = {
一: 1,
二: 2,
@@ -81,6 +82,7 @@ export interface VehicleModalSelection {
}
export interface VehicleListRequestParams {
inventoryKeys?: string[];
brand?: string;
batch?: string;
model?: string;
@@ -181,6 +183,7 @@ export function buildVehicleModalRequest(
}
const params: VehicleListRequestParams = {};
if (selection.inventoryKeys) params.inventoryKeys = selection.inventoryKeys;
if (selection.brand) params.brand = selection.brand;
if (selection.vehicleType) params.vehicleType = selection.vehicleType;
if (selection.batch !== 'All') params.batch = selection.batch;
@@ -239,7 +242,7 @@ export function filterInventoryStats(
(!filters.region || item.region === filters.region)
&& (!filters.city || item.city === filters.city)
&& (!filters.brand || item.brand === filters.brand)
&& (!filters.type || item.type === filters.type)
&& (!filters.type || inventoryTypeLabel(item.type) === inventoryTypeLabel(filters.type))
&& (!filters.model || item.model === filters.model)
));
}
@@ -261,9 +264,10 @@ export function groupInventoryByModel(
): Record<string, Record<string, RegionalInventoryStats[]>> {
const raw: Record<string, Record<string, RegionalInventoryStats[]>> = {};
for (const item of inventory) {
if (!raw[item.type]) raw[item.type] = {};
if (!raw[item.type][item.model]) raw[item.type][item.model] = [];
raw[item.type][item.model].push(item);
const type = inventoryTypeLabel(item.type);
if (!raw[type]) raw[type] = {};
if (!raw[type][item.model]) raw[type][item.model] = [];
raw[type][item.model].push(item);
}
// 已知车型按报表约定排序,新增的未知车型保留接口返回时的首次出现顺序。
@@ -284,9 +288,9 @@ export function deriveInventoryView(
) {
const filtered = filterInventoryStats(inventory, filters);
const modelSource = modelTypeFilter
? inventory.filter((item) => item.type === modelTypeFilter)
? inventory.filter((item) => inventoryTypeLabel(item.type) === inventoryTypeLabel(modelTypeFilter))
: inventory;
const types = uniqueNonEmpty(inventory.map((item) => item.type));
const types = uniqueNonEmpty(inventory.map((item) => inventoryTypeLabel(item.type)));
return {
filtered,
+14 -2
View File
@@ -354,8 +354,16 @@ app.get('/customer-stats', async (c) => {
return c.json(result);
});
// GET /api/vehicles/list — flat list with optional filters
app.get('/list', async (c) => {
// POST uses a JSON scope to avoid URL length limits for inventory subtotal drilldown.
app.on(['GET', 'POST'], '/list', async (c) => {
let inventoryKeys: Set<string> | undefined;
if (c.req.method === 'POST') {
const body = await c.req.json().catch(() => null);
if (!body || !Array.isArray(body.inventoryKeys) || !body.inventoryKeys.every((key: unknown) => typeof key === 'string')) {
return c.json({ error: '库存明细范围无效' }, 400);
}
inventoryKeys = new Set(body.inventoryKeys);
}
const vehicles = await getVehiclesForUser(c);
const { brand, batch, model, location, status, category, vehicleType, manager, customer, isColdChain, isTrailer, department, attendance } = c.req.query();
@@ -363,6 +371,10 @@ app.get('/list', async (c) => {
? vehicles.filter(v => isOperatingVehicle(v) || isInventoryPending(v))
: vehicles;
if (inventoryKeys) {
filtered = filtered.filter(v => isInventoryVehicle(v) && inventoryKeys.has(inventoryKey(inventoryDimensions(v))));
}
// attendance filter: active = day_mileage > 0, idle = day_mileage = 0 (only for Operating vehicles)
if (attendance === 'active' || attendance === 'idle') {
const realtimeRows = await vehicleRepository.getAttendanceMileageRows();
@@ -78,3 +78,31 @@ test('inventory composition and color variants reconcile with permission-filtere
assert.ok(list.every((vehicle: {inventoryKey:string}) => vehicle.inventoryKey === JSON.stringify([row.region,row.city,row.brand,row.type,row.model])));
assert.equal(list.filter((vehicle: {color:string}) => vehicle.color === '红').length,1);
});
test('inventory subtotal scopes intersect actual stock, company and exact city/brand/model records', async (t) => {
const make = (id: string, overrides: Partial<Vehicle> = {}): Vehicle => ({
id, plateNumber: `TEST-${id}`, vin: id, type: '18T', model: '飞驰18T白车', color: '白',
location: '嘉兴', region: '嘉兴', province: '浙江省', city: '嘉兴市',
status: 'Inventory', operationStatus: '3', ownership: 'Self', rentCompany: '',
contractNo: null, customerName: null, orgName: null, departmentName: null,
subjectOrg: '公司甲', projectName: null, customerManager: null, managerId: null,
brandLabel: '飞驰', ...overrides,
});
t.mock.method(vehicleRepository, 'getVehicles', async () => [
make('1'), make('2', { model: '飞驰18T红车', status: 'Pending' }),
make('3', { city: '金华市' }), make('4', { brandLabel: '其他品牌' }),
make('5', { operationStatus: '1', status: 'Operating' }), make('6', { subjectOrg: '公司乙' }),
]);
const stats = await (await app.request('/inventory-stats?subject=公司甲')).json();
const keys = stats.filter((row: { city: string; brand: string }) => row.city === '嘉兴市' && row.brand === '飞驰')
.map((row: { region: string; city: string; brand: string; type: string; model: string }) => JSON.stringify([row.region, row.city, row.brand, row.type, row.model]));
const request = (inventoryKeys: unknown) => app.request('/list?source=asset&subject=公司甲&category=Inventory', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ inventoryKeys }),
});
const response = await request(keys);
assert.equal(response.status, 200);
assert.deepEqual((await response.json()).map((row: { id: string }) => row.id), ['1', '2']);
assert.deepEqual(await (await request([])).json(), []);
assert.equal((await request(null)).status, 400);
assert.equal((await request([123])).status, 400);
});
+3
View File
@@ -116,6 +116,9 @@ test('keeps summary and list meanings of other vehicle types distinct', () => {
assert.equal(summaryOther?.filter(trailer), true);
assert.equal(VEHICLE_TYPE_FILTERS['其他'](trailer), false);
assert.equal(VEHICLE_TYPE_FILTERS['其他'](forklift), true);
assert.equal(VEHICLE_TYPE_FILTERS['库存其他'](trailer), true);
assert.equal(VEHICLE_TYPE_FILTERS['库存其他'](forklift), true);
assert.equal(VEHICLE_TYPE_FILTERS['库存其他'](vehicle({ type: '18T', model: '苏龙18T飞翼' })), false);
});
test('keeps location filtering semantics for the ambiguous other region', () => {
+1
View File
@@ -272,6 +272,7 @@ export const VEHICLE_TYPE_FILTERS: Record<string, (vehicle: Vehicle) => boolean>
'49T': (vehicle) => vehicle.type === '49T',
'挂车': (vehicle) => vehicle.type === '挂车' || vehicle.model.includes('挂车'),
'其他': (vehicle) => classifyVehicleType(vehicle) === 'other',
'库存其他': (vehicle) => ['trailer', 'other'].includes(classifyVehicleType(vehicle)),
};
export function filterByLocation(vehicles: Vehicle[], location: string, source?: string): Vehicle[] {
@@ -27,7 +27,9 @@ function vehicle(id: string): Vehicle {
test('keeps all vehicle GET endpoints in their established order', () => {
const source = readFileSync(new URL('../vehicles.ts', import.meta.url), 'utf8');
const paths = Array.from(source.matchAll(/app\.get\('([^']+)'/g), (match) => match[1]);
const paths = Array.from(
source.matchAll(/app\.get\('([^']+)'|app\.on\(\[([^\]]+)\],\s*'([^']+)'/g),
).flatMap((match) => match[1] ? [match[1]] : /'GET'/.test(match[2]) ? [match[3]] : []);
assert.deepEqual(paths, [
'/summary',