From b5a48b641766c49dccd93ed84c7b20960426498d Mon Sep 17 00:00:00 2001 From: shishengliang Date: Wed, 23 Sep 2026 17:52:33 +0800 Subject: [PATCH] feat(assets): refine compact inventory drilldowns and exact subtotal scopes --- src/modules/assets/api.ts | 7 +- .../assets/components/VehicleDetailModal.tsx | 4 +- .../overview/InventoryDesktopTable.tsx | 38 ++-- .../overview/InventoryMobileList.tsx | 88 ++++++--- .../overview/InventoryStatisticsSection.tsx | 8 +- .../overview/InventoryStatusCounts.tsx | 4 +- .../overview/inventory-presentation.test.ts | 147 +++++++++++++- .../overview/inventory-presentation.ts | 93 ++++++++- .../overview/inventory-statistics.css | 186 +++++++++++++----- src/modules/assets/model.ts | 18 +- src/server/routes/vehicles.ts | 16 +- src/server/routes/vehicles/drilldown.test.ts | 28 +++ src/server/routes/vehicles/model.test.ts | 3 + src/server/routes/vehicles/model.ts | 1 + src/server/routes/vehicles/repository.test.ts | 4 +- 15 files changed, 534 insertions(+), 111 deletions(-) diff --git a/src/modules/assets/api.ts b/src/modules/assets/api.ts index 30ac0cc..861a37f 100644 --- a/src/modules/assets/api.ts +++ b/src/modules/assets/api.ts @@ -37,6 +37,7 @@ export async function fetchByType(subject?: string | null): Promise(`${BASE}/list?${query.toString()}`); + return fetchJson(`${BASE}/list?${query.toString()}`, params.inventoryKeys ? { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ inventoryKeys: params.inventoryKeys }), + } : undefined); } export interface WeeklyDetailItem { diff --git a/src/modules/assets/components/VehicleDetailModal.tsx b/src/modules/assets/components/VehicleDetailModal.tsx index 552b157..970c509 100644 --- a/src/modules/assets/components/VehicleDetailModal.tsx +++ b/src/modules/assets/components/VehicleDetailModal.tsx @@ -67,14 +67,14 @@ export function VehicleDetailModal({ )}

- {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' ? '正在运营' : '全部状态'}}

+ + ; +} + function InventoryDetailRows({ rows, city, tab, total, select }: Pick & { 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) => - {tab === 'region' && brandIndex === 0 && variantIndex === 0 && index === 0 && {city || '未分配'}} - {variantIndex === 0 && index === 0 && {brand.brand || '—'}} + {tab === 'region' && brandIndex === 0 && variantIndex === 0 && index === 0 && {city || '未分配'}} + {variantIndex === 0 && index === 0 && {brand.brand || '—'}} {index === 0 && - {tab === 'region' ? {row.type || '未分类'} : } + {tab === 'region' ? {inventoryTypeLabel(row.type) || '未分类'} : } + } - {tab === 'region' ? : <>{row.region}{row.city}} + {tab === 'model' && regionSpan > 0 && {row.region || '未分配'} item.region === row.region)} label={row.region || '未分配'} />} + {tab === 'region' ? : row.city || '未分配'} selectInventoryRow(row, select)} /> @@ -62,14 +72,14 @@ export function InventoryDesktopTable(props: InventoryListProps) { {tab === 'region' && 区域 / 城市} 品牌 {tab === 'region' && 车型} - 类别 / 颜色 / 归属方 - {tab === 'model' && 区域 / 城市} + 类别 + {tab === 'model' && <>区域城市} 库存数量 库存占比 {groups.map(group => - - + +
+ {view.parents.map((parent, index) => + )} + } +
+

{labels[view.dimension]}库存 · {view.groups.length} 项

+ {view.parents.length > 0 && } +
+
{labels[view.dimension]}库存(台)
+
    + {pageGroups.map(item =>
  • + + openVehicles(item.rows, item.label)} /> + {item.rows.some(row => row.abnormal || row.pending) &&
    } +
  • )} +
+
当前范围合计{inventoryTotal(view.rows).toLocaleString()} 台
+ {pageCount > 1 && } +
; +} export function InventoryMobileList(props: InventoryListProps) { - const { groups, tab, expanded, toggle, total, select } = props; + const { groups, tab, total } = props; + const [activeGroup, setActiveGroup] = useState(null); + const id = useId(); return
- {groups.map(group =>
+

点击名称逐层查看,点击数量查看车辆

+ {groups.map((group, index) =>
- selectInventoryGroup(group, props)} />
库存占比
{(group.rows.some(row => row.abnormal || row.pending)) &&
} - {expanded.has(group.name) &&
- {(tab === 'region' ? group.sections : [[group.name, group.rows] as const]).map(([name, rows]) =>
- {tab === 'region' &&

{name || '未分配'}

} - {inventoryBrandGroups(rows, tab === 'region' ? 'type' : 'model').map(brand =>
-

{brand.brand || '未分配品牌'}

- {brand.variants.map((variants, variantIndex) =>
-
{tab === 'region' ? {variants[0].type || '未分类'} : }
- {variants.map((row, index) =>
-
{tab === 'region' ? : <>{row.region}{row.city}}
- selectInventoryRow(row, select)} /> - {(row.abnormal || row.pending) ?
: null} -
)} -
)} -
)} -
)} -
} +
)}
; } diff --git a/src/modules/assets/components/overview/InventoryStatisticsSection.tsx b/src/modules/assets/components/overview/InventoryStatisticsSection.tsx index ad0ca91..c899edf 100644 --- a/src/modules/assets/components/overview/InventoryStatisticsSection.tsx +++ b/src/modules/assets/components/overview/InventoryStatisticsSection.tsx @@ -43,7 +43,7 @@ export function InventoryStatisticsSection(props: InventoryStatisticsSectionProp
-

库存统计

从区域与车型查看库存分布,点击数量查看车辆明细

+

库存统计

按区域、车型核对库存分布

- - {groups.length ? <> : + {groups.length ? <> :
}
共 {groups.length} 个{tab === 'region' ? '区域' : '车型'} · {rows.length} 条记录 · {total.toLocaleString()} 台库存占比基于当前筛选结果 · 单位:台
diff --git a/src/modules/assets/components/overview/InventoryStatusCounts.tsx b/src/modules/assets/components/overview/InventoryStatusCounts.tsx index cd8a791..3715988 100644 --- a/src/modules/assets/components/overview/InventoryStatusCounts.tsx +++ b/src/modules/assets/components/overview/InventoryStatusCounts.tsx @@ -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 - {abnormal > 0 && 其中异动 {abnormal} 台} - {pending > 0 && 其中待交车 {pending} 台} + {abnormal > 0 && 其中异动 {abnormal} 台} + {pending > 0 && 其中待交车 {pending} 台} ; } diff --git a/src/modules/assets/components/overview/inventory-presentation.test.ts b/src/modules/assets/components/overview/inventory-presentation.test.ts index b3546d7..22ff92a 100644 --- a/src/modules/assets/components/overview/inventory-presentation.test.ts +++ b/src/modules/assets/components/overview/inventory-presentation.test.ts @@ -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, /其中待交车 2<\/strong> 台/); + assert.match(html, /其中异动 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, '其他'); +}); diff --git a/src/modules/assets/components/overview/inventory-presentation.ts b/src/modules/assets/components/overview/inventory-presentation.ts index 6416dc8..df52d8a 100644 --- a/src/modules/assets/components/overview/inventory-presentation.ts +++ b/src/modules/assets/components/overview/inventory-presentation.ts @@ -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 & Partial>) { 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(); + 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 }>(); @@ -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(); + 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 = []; + 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(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>, 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]) => [ diff --git a/src/modules/assets/components/overview/inventory-statistics.css b/src/modules/assets/components/overview/inventory-statistics.css index dfedcb1..14e42ba 100644 --- a/src/modules/assets/components/overview/inventory-statistics.css +++ b/src/modules/assets/components/overview/inventory-statistics.css @@ -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; } diff --git a/src/modules/assets/model.ts b/src/modules/assets/model.ts index 42b3582..50ce2e2 100644 --- a/src/modules/assets/model.ts +++ b/src/modules/assets/model.ts @@ -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 = { 一: 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> { const raw: Record> = {}; 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, diff --git a/src/server/routes/vehicles.ts b/src/server/routes/vehicles.ts index 9e42798..fc7140e 100644 --- a/src/server/routes/vehicles.ts +++ b/src/server/routes/vehicles.ts @@ -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 | 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(); diff --git a/src/server/routes/vehicles/drilldown.test.ts b/src/server/routes/vehicles/drilldown.test.ts index 4ef9911..3f03b76 100644 --- a/src/server/routes/vehicles/drilldown.test.ts +++ b/src/server/routes/vehicles/drilldown.test.ts @@ -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 => ({ + 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); +}); diff --git a/src/server/routes/vehicles/model.test.ts b/src/server/routes/vehicles/model.test.ts index ae5eb00..5695ef1 100644 --- a/src/server/routes/vehicles/model.test.ts +++ b/src/server/routes/vehicles/model.test.ts @@ -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', () => { diff --git a/src/server/routes/vehicles/model.ts b/src/server/routes/vehicles/model.ts index f793c3d..91b32f4 100644 --- a/src/server/routes/vehicles/model.ts +++ b/src/server/routes/vehicles/model.ts @@ -272,6 +272,7 @@ export const VEHICLE_TYPE_FILTERS: Record 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[] { diff --git a/src/server/routes/vehicles/repository.test.ts b/src/server/routes/vehicles/repository.test.ts index 7676e0d..ddd41f4 100644 --- a/src/server/routes/vehicles/repository.test.ts +++ b/src/server/routes/vehicles/repository.test.ts @@ -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',