413 lines
14 KiB
TypeScript
413 lines
14 KiB
TypeScript
import type { FlowStatsResponse, FlowType, WeeklyDetailItem } from './api';
|
|
import type {
|
|
CustomerStats,
|
|
DeptGroup,
|
|
RegionalInventoryStats,
|
|
VehicleListItem,
|
|
} from './types';
|
|
|
|
const INVENTORY_TYPE_ORDER = ['4.5T普货', '4.5T冷链', '18T', '49T', '挂车', '其他'];
|
|
const CHINESE_NUMBER_ORDER: Record<string, number> = {
|
|
一: 1,
|
|
二: 2,
|
|
三: 3,
|
|
四: 4,
|
|
五: 5,
|
|
六: 6,
|
|
七: 7,
|
|
八: 8,
|
|
九: 9,
|
|
十: 10,
|
|
};
|
|
|
|
export interface DateRange {
|
|
start: string;
|
|
end: string;
|
|
}
|
|
|
|
export interface InventoryFilters {
|
|
region: string;
|
|
city: string;
|
|
brand: string;
|
|
type: string;
|
|
model: string;
|
|
}
|
|
|
|
export interface CustomerFilters {
|
|
customer: string[];
|
|
brand: string;
|
|
department: string;
|
|
manager: string;
|
|
region: string;
|
|
}
|
|
|
|
export interface ModalVehicleFilters {
|
|
plateNumber: string;
|
|
model: string;
|
|
brand: string;
|
|
location: string;
|
|
}
|
|
|
|
export type VehicleModalCategory =
|
|
| 'Inventory'
|
|
| 'Pending'
|
|
| 'Delivered'
|
|
| 'Returned'
|
|
| 'Replaced'
|
|
| 'Operating';
|
|
|
|
export interface VehicleModalSelection {
|
|
batch: string;
|
|
model: string;
|
|
location: string;
|
|
category?: VehicleModalCategory;
|
|
vehicleType?: string;
|
|
manager?: string;
|
|
customer?: string;
|
|
department?: string;
|
|
attendance?: 'active' | 'idle';
|
|
isColdChain?: boolean;
|
|
isTrailer?: boolean;
|
|
type?: string;
|
|
source?: string;
|
|
title?: string;
|
|
}
|
|
|
|
export interface VehicleListRequestParams {
|
|
batch?: string;
|
|
model?: string;
|
|
location?: string;
|
|
category?: 'Inventory' | 'Operating' | 'Pending';
|
|
vehicleType?: string;
|
|
manager?: string;
|
|
customer?: string;
|
|
isColdChain?: string;
|
|
isTrailer?: string;
|
|
department?: string;
|
|
attendance?: string;
|
|
subject?: string | null;
|
|
source?: string;
|
|
}
|
|
|
|
export type VehicleModalRequest =
|
|
| {
|
|
kind: 'weekly';
|
|
type: FlowType;
|
|
filters: { model: string; batch: string; location: string; source?: string };
|
|
}
|
|
| { kind: 'vehicles'; params: VehicleListRequestParams };
|
|
|
|
export interface FlowSelection {
|
|
date: string;
|
|
type: FlowType;
|
|
}
|
|
|
|
export function formatLocalDateTime(date: Date): string {
|
|
const year = date.getFullYear();
|
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
const day = String(date.getDate()).padStart(2, '0');
|
|
const hours = String(date.getHours()).padStart(2, '0');
|
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
const seconds = String(date.getSeconds()).padStart(2, '0');
|
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
|
}
|
|
|
|
export function formatLocalDate(date: Date): string {
|
|
const year = date.getFullYear();
|
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
const day = String(date.getDate()).padStart(2, '0');
|
|
return `${year}-${month}-${day}`;
|
|
}
|
|
|
|
function addDays(date: Date, days: number): Date {
|
|
const next = new Date(date);
|
|
next.setDate(next.getDate() + days);
|
|
return next;
|
|
}
|
|
|
|
// 资产流转周报按周六至周五统计;周末查看时仍停留在刚结束的周五。
|
|
export function getWeeklyFlowRange(referenceDate = new Date()): DateRange {
|
|
const day = referenceDate.getDay();
|
|
const end = day === 6
|
|
? addDays(referenceDate, -1)
|
|
: day === 0
|
|
? addDays(referenceDate, -2)
|
|
: addDays(referenceDate, 5 - day);
|
|
return {
|
|
start: formatLocalDate(addDays(end, -6)),
|
|
end: formatLocalDate(end),
|
|
};
|
|
}
|
|
|
|
const WEEKLY_FLOW_TYPE_BY_CATEGORY: Partial<Record<VehicleModalCategory, FlowType>> = {
|
|
Delivered: 'delivered',
|
|
Returned: 'returned',
|
|
Replaced: 'replaced',
|
|
};
|
|
|
|
// Pending 不是周流转事件,必须继续走车辆列表接口以保留型号、批次和区域筛选。
|
|
export function buildVehicleModalRequest(
|
|
selection: VehicleModalSelection,
|
|
subject: string | null,
|
|
): VehicleModalRequest {
|
|
const weeklyType = selection.category
|
|
? WEEKLY_FLOW_TYPE_BY_CATEGORY[selection.category]
|
|
: undefined;
|
|
if (weeklyType) {
|
|
return {
|
|
kind: 'weekly',
|
|
type: weeklyType,
|
|
filters: {
|
|
model: selection.model,
|
|
batch: selection.batch,
|
|
location: selection.location,
|
|
source: selection.source,
|
|
},
|
|
};
|
|
}
|
|
|
|
const params: VehicleListRequestParams = {};
|
|
if (selection.vehicleType) params.vehicleType = selection.vehicleType;
|
|
if (selection.batch !== 'All') params.batch = selection.batch;
|
|
if (selection.model !== 'All') params.model = selection.model;
|
|
if (selection.location !== 'All') params.location = selection.location;
|
|
if (selection.source) params.source = selection.source;
|
|
if (selection.category === 'Inventory') params.category = 'Inventory';
|
|
if (selection.category === 'Operating') params.category = 'Operating';
|
|
if (selection.category === 'Pending') params.category = 'Pending';
|
|
if (selection.manager) params.manager = selection.manager;
|
|
if (selection.customer) params.customer = selection.customer;
|
|
if (selection.department) params.department = selection.department;
|
|
if (selection.attendance) params.attendance = selection.attendance;
|
|
|
|
if (!selection.type) {
|
|
if (selection.isColdChain !== undefined) params.isColdChain = String(selection.isColdChain);
|
|
if (selection.isTrailer !== undefined) params.isTrailer = String(selection.isTrailer);
|
|
}
|
|
|
|
// 页面车型分组与列表接口的 vehicleType 取值并不完全一致,这里集中保留既有映射。
|
|
if (selection.type === '4.5T') {
|
|
if (selection.isColdChain === true) params.vehicleType = '4.5T冷链';
|
|
if (selection.isColdChain === false) params.vehicleType = '4.5T普货';
|
|
} else if (
|
|
selection.type === '4.5T普货'
|
|
|| selection.type === '4.5T冷链'
|
|
|| selection.type === '18T'
|
|
|| selection.type === '49T'
|
|
|| selection.type === '挂车'
|
|
|| selection.type === '其他'
|
|
) {
|
|
params.vehicleType = selection.type;
|
|
} else if (selection.type === '其他车型') {
|
|
if (selection.isTrailer === true) params.isTrailer = 'true';
|
|
if (selection.isTrailer === false) params.vehicleType = '其他';
|
|
}
|
|
|
|
return { kind: 'vehicles', params: { ...params, subject } };
|
|
}
|
|
|
|
function uniqueNonEmpty(values: Array<string | null | undefined>): string[] {
|
|
return Array.from(new Set(values.filter((value): value is string => Boolean(value))));
|
|
}
|
|
|
|
function getDepartmentOrder(name: string): number {
|
|
const match = name.match(/[一二三四五六七八九十]/);
|
|
return match ? (CHINESE_NUMBER_ORDER[match[0]] || 99) : 99;
|
|
}
|
|
|
|
export function filterInventoryStats(
|
|
inventory: RegionalInventoryStats[],
|
|
filters: InventoryFilters,
|
|
): RegionalInventoryStats[] {
|
|
return inventory.filter((item) => (
|
|
(!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.model || item.model === filters.model)
|
|
));
|
|
}
|
|
|
|
export function groupInventoryByRegion(
|
|
inventory: RegionalInventoryStats[],
|
|
): Record<string, Record<string, RegionalInventoryStats[]>> {
|
|
const result: Record<string, Record<string, RegionalInventoryStats[]>> = {};
|
|
for (const item of inventory) {
|
|
if (!result[item.region]) result[item.region] = {};
|
|
if (!result[item.region][item.city]) result[item.region][item.city] = [];
|
|
result[item.region][item.city].push(item);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export function groupInventoryByModel(
|
|
inventory: RegionalInventoryStats[],
|
|
): 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 result: Record<string, Record<string, RegionalInventoryStats[]>> = {};
|
|
for (const type of INVENTORY_TYPE_ORDER) {
|
|
if (raw[type]) result[type] = raw[type];
|
|
}
|
|
for (const type of Object.keys(raw)) {
|
|
if (!result[type]) result[type] = raw[type];
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export function deriveInventoryView(
|
|
inventory: RegionalInventoryStats[],
|
|
filters: InventoryFilters,
|
|
modelTypeFilter: string,
|
|
) {
|
|
const filtered = filterInventoryStats(inventory, filters);
|
|
const modelSource = modelTypeFilter
|
|
? inventory.filter((item) => item.type === modelTypeFilter)
|
|
: inventory;
|
|
const types = uniqueNonEmpty(inventory.map((item) => item.type));
|
|
|
|
return {
|
|
filtered,
|
|
brands: uniqueNonEmpty(inventory.map((item) => item.brand)),
|
|
regions: Array.from(new Set(inventory.map((item) => item.region))),
|
|
cities: uniqueNonEmpty(inventory.map((item) => item.city)),
|
|
types: types.sort(
|
|
(left, right) => INVENTORY_TYPE_ORDER.indexOf(left) - INVENTORY_TYPE_ORDER.indexOf(right),
|
|
),
|
|
modelsForType: uniqueNonEmpty(modelSource.map((item) => item.model)),
|
|
byRegion: groupInventoryByRegion(filtered),
|
|
byModel: groupInventoryByModel(filtered),
|
|
};
|
|
}
|
|
|
|
export function deriveDepartmentView(departments: DeptGroup[], selectedManager: string) {
|
|
const managers = departments
|
|
.flatMap((department) => department.managers.map((manager) => manager.manager))
|
|
.filter((value, index, all) => all.indexOf(value) === index)
|
|
.sort();
|
|
const groupedManagers = departments.map((department) => ({
|
|
department: department.department,
|
|
managers: department.managers.map((manager) => manager.manager),
|
|
}));
|
|
const managerStats = departments
|
|
.flatMap((department) => department.managers)
|
|
.filter((manager) => selectedManager === 'All' || manager.manager === selectedManager)
|
|
.sort((left, right) => right.total - left.total);
|
|
|
|
return { managers, groupedManagers, managerStats };
|
|
}
|
|
|
|
export function filterCustomerStats(
|
|
customers: CustomerStats[],
|
|
filters: CustomerFilters,
|
|
): CustomerStats[] {
|
|
return customers.filter((customer) => (
|
|
(filters.customer.length === 0 || filters.customer.includes(customer.customer))
|
|
&& (!filters.brand || customer.brand === filters.brand)
|
|
&& (!filters.department || customer.department === filters.department)
|
|
&& (!filters.manager || customer.manager === filters.manager)
|
|
&& (!filters.region || customer.region === filters.region)
|
|
));
|
|
}
|
|
|
|
function groupCustomerManagers(
|
|
customers: CustomerStats[],
|
|
departments: DeptGroup[],
|
|
): Array<{ department: string; managers: string[] }> {
|
|
const departmentManagers = new Map<string, Set<string>>();
|
|
for (const department of departments) {
|
|
if (!departmentManagers.has(department.department)) {
|
|
departmentManagers.set(department.department, new Set());
|
|
}
|
|
for (const manager of department.managers) {
|
|
departmentManagers.get(department.department)!.add(manager.manager);
|
|
}
|
|
}
|
|
for (const customer of customers) {
|
|
if (!customer.manager || !customer.department) continue;
|
|
if (!departmentManagers.has(customer.department)) {
|
|
departmentManagers.set(customer.department, new Set());
|
|
}
|
|
departmentManagers.get(customer.department)!.add(customer.manager);
|
|
}
|
|
|
|
return Array.from(departmentManagers.entries())
|
|
.sort((left, right) => {
|
|
const leftOrder = left[0] === '公务车' ? 100 : getDepartmentOrder(left[0]);
|
|
const rightOrder = right[0] === '公务车' ? 100 : getDepartmentOrder(right[0]);
|
|
return leftOrder - rightOrder;
|
|
})
|
|
.map(([department, managers]) => ({ department, managers: Array.from(managers) }));
|
|
}
|
|
|
|
export function deriveCustomerView(
|
|
customers: CustomerStats[],
|
|
departments: DeptGroup[],
|
|
filters: CustomerFilters,
|
|
) {
|
|
return {
|
|
filtered: filterCustomerStats(customers, filters),
|
|
brands: uniqueNonEmpty(customers.map((customer) => customer.brand)),
|
|
departments: uniqueNonEmpty(customers.map((customer) => customer.department))
|
|
.sort((left, right) => getDepartmentOrder(left) - getDepartmentOrder(right)),
|
|
regions: Array.from(new Set(customers.map((customer) => customer.region))),
|
|
cities: uniqueNonEmpty(customers.map((customer) => customer.city)),
|
|
customerNames: uniqueNonEmpty(customers.map((customer) => customer.customer)),
|
|
managersByDepartment: groupCustomerManagers(customers, departments),
|
|
};
|
|
}
|
|
|
|
export function deriveModalVehicleView(
|
|
vehicles: VehicleListItem[],
|
|
weeklyDetails: WeeklyDetailItem[],
|
|
filters: ModalVehicleFilters,
|
|
) {
|
|
return {
|
|
plates: uniqueNonEmpty(vehicles.map((vehicle) => vehicle.plateNumber || vehicle.vin)),
|
|
models: uniqueNonEmpty(vehicles.map((vehicle) => vehicle.model)),
|
|
brands: uniqueNonEmpty(vehicles.map((vehicle) => vehicle.brandLabel)),
|
|
locations: uniqueNonEmpty(vehicles.map((vehicle) => vehicle.location)),
|
|
filteredVehicles: vehicles.filter((vehicle) => (
|
|
(!filters.plateNumber || (vehicle.plateNumber || vehicle.vin) === filters.plateNumber)
|
|
&& (!filters.model || vehicle.model === filters.model)
|
|
&& (!filters.brand || vehicle.brandLabel === filters.brand)
|
|
&& (!filters.location || vehicle.location === filters.location)
|
|
)),
|
|
filteredWeeklyDetails: weeklyDetails.filter((detail) => (
|
|
!filters.plateNumber || detail.plate_number === filters.plateNumber
|
|
)),
|
|
};
|
|
}
|
|
|
|
export function selectFlowDetails(
|
|
flowStats: FlowStatsResponse | null,
|
|
selection: FlowSelection | null,
|
|
) {
|
|
if (!flowStats || !selection) return [];
|
|
return flowStats.details.filter((detail) => (
|
|
detail.date === selection.date && detail.type === selection.type
|
|
));
|
|
}
|
|
|
|
export function buildCustomerPieData(
|
|
customers: CustomerStats[],
|
|
view: 'region' | 'province',
|
|
provinceData: Array<{ name: string; value: number }>,
|
|
): Array<{ name: string; value: number }> {
|
|
if (view === 'province') return provinceData;
|
|
|
|
const totals: Record<string, number> = {};
|
|
for (const customer of customers) {
|
|
totals[customer.region] = (totals[customer.region] || 0) + customer.total;
|
|
}
|
|
return Object.entries(totals)
|
|
.map(([name, value]) => ({ name, value }))
|
|
.sort((left, right) => right.value - left.value);
|
|
}
|