285 lines
8.9 KiB
TypeScript
285 lines
8.9 KiB
TypeScript
import type {
|
|
CostDim,
|
|
FleetScope,
|
|
H2OrderRow,
|
|
LeaseKind,
|
|
OpsKind,
|
|
} from '../types';
|
|
import { COST_DIM_LABEL, LEASE_KIND_LABEL, OPS_KIND_LABEL } from '../types';
|
|
|
|
export function filterOrders(
|
|
rows: H2OrderRow[],
|
|
year: number,
|
|
verifyScope: 'all' | 'verified',
|
|
fleetScope: FleetScope,
|
|
): H2OrderRow[] {
|
|
return rows.filter((r) => {
|
|
if (!r.occurredAt.startsWith(String(year))) return false;
|
|
if (verifyScope === 'verified' && r.verifyStatus !== 'verified') return false;
|
|
if (fleetScope === 'own' && r.fleet !== 'own') return false;
|
|
if (fleetScope === 'external' && r.fleet !== 'external') return false;
|
|
return true;
|
|
});
|
|
}
|
|
|
|
export function sumAmount(rows: H2OrderRow[]): number {
|
|
return rows.reduce((s, r) => s + r.amount, 0);
|
|
}
|
|
|
|
export function sumKg(rows: H2OrderRow[]): number {
|
|
return rows.reduce((s, r) => s + r.quantityKg, 0);
|
|
}
|
|
|
|
export function companyCostRows(rows: H2OrderRow[]): H2OrderRow[] {
|
|
return rows.filter((r) => r.borneBy === 'company');
|
|
}
|
|
|
|
export function dimAmount(rows: H2OrderRow[], dim: CostDim): number {
|
|
return sumAmount(companyCostRows(rows).filter((r) => r.costDim === dim));
|
|
}
|
|
|
|
export function leaseSubAmount(rows: H2OrderRow[], kind: LeaseKind): number {
|
|
return sumAmount(
|
|
companyCostRows(rows).filter((r) => r.costDim === 'lease' && r.leaseKind === kind),
|
|
);
|
|
}
|
|
|
|
export function opsSubAmount(rows: H2OrderRow[], kind: OpsKind): number {
|
|
return sumAmount(
|
|
companyCostRows(rows).filter((r) => r.costDim === 'ops' && r.opsKind === kind),
|
|
);
|
|
}
|
|
|
|
export interface DimCard {
|
|
key: CostDim;
|
|
label: string;
|
|
amount: number;
|
|
subs: { key: string; label: string; amount: number }[];
|
|
}
|
|
|
|
export function costDimCards(rows: H2OrderRow[]): DimCard[] {
|
|
return [
|
|
{
|
|
key: 'lease',
|
|
label: COST_DIM_LABEL.lease,
|
|
amount: dimAmount(rows, 'lease'),
|
|
subs: [
|
|
{ key: 'company_borne', label: LEASE_KIND_LABEL.company_borne, amount: leaseSubAmount(rows, 'company_borne') },
|
|
{ key: 'package_h2', label: LEASE_KIND_LABEL.package_h2, amount: leaseSubAmount(rows, 'package_h2') },
|
|
],
|
|
},
|
|
{
|
|
key: 'logistics',
|
|
label: COST_DIM_LABEL.logistics,
|
|
amount: dimAmount(rows, 'logistics'),
|
|
subs: [],
|
|
},
|
|
{
|
|
key: 'ops',
|
|
label: COST_DIM_LABEL.ops,
|
|
amount: dimAmount(rows, 'ops'),
|
|
subs: [
|
|
{ key: 'abnormal', label: OPS_KIND_LABEL.abnormal, amount: opsSubAmount(rows, 'abnormal') },
|
|
{ key: 'transfer', label: OPS_KIND_LABEL.transfer, amount: opsSubAmount(rows, 'transfer') },
|
|
],
|
|
},
|
|
];
|
|
}
|
|
|
|
export function pendingAmount(rows: H2OrderRow[]): number {
|
|
return dimAmount(rows, 'pending');
|
|
}
|
|
|
|
export function unverified(rows: H2OrderRow[]): { amount: number; count: number } {
|
|
const list = rows.filter((r) => r.verifyStatus === 'unverified');
|
|
return { amount: sumAmount(list), count: list.length };
|
|
}
|
|
|
|
export function formatYuan(n: number): string {
|
|
return `¥${n.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`;
|
|
}
|
|
|
|
export function formatKg(n: number): string {
|
|
return `${n.toLocaleString('zh-CN', { maximumFractionDigits: 2 })} kg`;
|
|
}
|
|
|
|
export function costDimLabel(row: H2OrderRow): string {
|
|
if (row.costDim === 'lease' && row.leaseKind) {
|
|
return `${COST_DIM_LABEL.lease} · ${LEASE_KIND_LABEL[row.leaseKind]}`;
|
|
}
|
|
if (row.costDim === 'ops' && row.opsKind) {
|
|
return `${COST_DIM_LABEL.ops} · ${OPS_KIND_LABEL[row.opsKind]}`;
|
|
}
|
|
return COST_DIM_LABEL[row.costDim];
|
|
}
|
|
|
|
export type DimFilter =
|
|
| { dim: CostDim; sub?: string }
|
|
| null;
|
|
|
|
export function applyDimFilter(rows: H2OrderRow[], filter: DimFilter): H2OrderRow[] {
|
|
if (!filter) return rows;
|
|
return rows.filter((r) => {
|
|
if (r.borneBy !== 'company') return false;
|
|
if (r.costDim !== filter.dim) return false;
|
|
if (!filter.sub) return true;
|
|
if (filter.dim === 'lease') return r.leaseKind === filter.sub;
|
|
if (filter.dim === 'ops') return r.opsKind === filter.sub;
|
|
return true;
|
|
});
|
|
}
|
|
|
|
/** 统计/明细共用:维度筛后的我司成本行;无维度筛则全部我司行 */
|
|
export function companyRowsForStats(rows: H2OrderRow[], filter: DimFilter): H2OrderRow[] {
|
|
if (!filter) return companyCostRows(rows);
|
|
return applyDimFilter(rows, filter);
|
|
}
|
|
|
|
export interface StationMonthRow {
|
|
stationId: string;
|
|
stationName: string;
|
|
month: string;
|
|
amount: number;
|
|
quantityKg: number;
|
|
unverifiedAmount: number;
|
|
}
|
|
|
|
export function stationMonthAgg(rows: H2OrderRow[]): StationMonthRow[] {
|
|
const map = new Map<string, H2OrderRow[]>();
|
|
rows.forEach((r) => {
|
|
const month = r.occurredAt.slice(0, 7);
|
|
const key = `${r.stationId}|${month}`;
|
|
const list = map.get(key) ?? [];
|
|
list.push(r);
|
|
map.set(key, list);
|
|
});
|
|
return Array.from(map.entries())
|
|
.map(([key, list]) => {
|
|
const [stationId, month] = key.split('|');
|
|
return {
|
|
stationId,
|
|
stationName: list[0].stationName,
|
|
month,
|
|
amount: sumAmount(list),
|
|
quantityKg: sumKg(list),
|
|
unverifiedAmount: sumAmount(list.filter((x) => x.verifyStatus === 'unverified')),
|
|
};
|
|
})
|
|
.sort((a, b) => b.amount - a.amount);
|
|
}
|
|
|
|
export interface CustomerAttrRow {
|
|
customerId: string;
|
|
customerName: string;
|
|
borneLabel: string;
|
|
quantityKg: number;
|
|
companyCost: number;
|
|
unverifiedAmount: number;
|
|
}
|
|
|
|
export function customerAttrAgg(rows: H2OrderRow[]): CustomerAttrRow[] {
|
|
const map = new Map<string, H2OrderRow[]>();
|
|
rows.forEach((r) => {
|
|
const list = map.get(r.customerId) ?? [];
|
|
list.push(r);
|
|
map.set(r.customerId, list);
|
|
});
|
|
return Array.from(map.entries())
|
|
.map(([customerId, list]) => {
|
|
const company = list.filter((x) => x.borneBy === 'company');
|
|
const customer = list.filter((x) => x.borneBy === 'customer');
|
|
let borneLabel = '混合';
|
|
if (company.length && !customer.length) borneLabel = '我司';
|
|
else if (customer.length && !company.length) borneLabel = '客户';
|
|
return {
|
|
customerId,
|
|
customerName: list[0].customerName,
|
|
borneLabel,
|
|
quantityKg: sumKg(list),
|
|
companyCost: sumAmount(company),
|
|
unverifiedAmount: sumAmount(list.filter((x) => x.verifyStatus === 'unverified')),
|
|
};
|
|
})
|
|
.sort((a, b) => b.companyCost - a.companyCost || b.quantityKg - a.quantityKg);
|
|
}
|
|
|
|
export const SOURCE_LABEL: Record<H2OrderRow['source'], string> = {
|
|
api: 'API',
|
|
manual: '补录',
|
|
fence: '围栏',
|
|
};
|
|
|
|
/** 总览 KPI:在宿主示意量级上按当前筛选(年/车辆/核对)等比缩放,保证卡片跟随顶栏筛选 */
|
|
export function computeHostKpi(
|
|
filtered: H2OrderRow[],
|
|
year: number,
|
|
allOrders: H2OrderRow[],
|
|
base: {
|
|
totalKgT: number;
|
|
companyKgT: number;
|
|
customerKgT: number;
|
|
totalFeeWan: number;
|
|
companyFeeWan: number;
|
|
customerFeeWan: number;
|
|
profitWan: number;
|
|
incomeWan: number;
|
|
costWan: number;
|
|
monthKgT: number;
|
|
monthFeeWan: number;
|
|
monthYearPct: number;
|
|
dayKg: number;
|
|
dayFee: number;
|
|
dayMonthPct: number;
|
|
},
|
|
) {
|
|
const round2 = (n: number) => Math.round(n * 100) / 100;
|
|
const baseline = filterOrders(allOrders, year, 'all', 'all');
|
|
const baseKg = sumKg(baseline) || 1;
|
|
const fKg = sumKg(filtered);
|
|
const ratio = fKg / baseKg;
|
|
|
|
const companyKg = sumKg(filtered.filter((r) => r.borneBy === 'company'));
|
|
const customerKg = sumKg(filtered.filter((r) => r.borneBy === 'customer'));
|
|
const split = companyKg + customerKg || 1;
|
|
const companyShare = companyKg / split;
|
|
const customerShare = customerKg / split;
|
|
|
|
const monthRows = filtered.filter((r) => r.occurredAt.startsWith(`${year}-08`));
|
|
const dayRows = filtered.filter((r) => r.occurredAt.startsWith(`${year}-08-08`));
|
|
const monthKg = sumKg(monthRows);
|
|
const dayKgVal = sumKg(dayRows);
|
|
const monthAmt = sumAmount(monthRows);
|
|
const dayAmt = sumAmount(dayRows);
|
|
const yearKg = fKg || 1;
|
|
const monthKgShare = monthKg / yearKg;
|
|
const dayMonthShare = monthKg > 0 ? dayKgVal / monthKg : 0;
|
|
|
|
const totalKgT = round2(base.totalKgT * ratio);
|
|
const totalFeeWan = round2(base.totalFeeWan * ratio);
|
|
const incomeWan = round2(base.incomeWan * ratio);
|
|
const costWan = round2(base.costWan * ratio);
|
|
const profitWan = round2(base.profitWan * ratio);
|
|
const monthKgT = round2(totalKgT * monthKgShare);
|
|
const monthFeeWan = round2(totalFeeWan * monthKgShare);
|
|
|
|
return {
|
|
totalKgT,
|
|
companyKgT: round2(totalKgT * companyShare),
|
|
customerKgT: round2(totalKgT * customerShare),
|
|
totalFeeWan,
|
|
companyFeeWan: round2(totalFeeWan * companyShare),
|
|
customerFeeWan: round2(totalFeeWan * customerShare),
|
|
profitWan,
|
|
incomeWan,
|
|
costWan,
|
|
monthKgT,
|
|
monthFeeWan,
|
|
monthYearPct: round2(monthKgShare * 100),
|
|
dayKg: round2(dayKgVal > 0 ? dayKgVal : base.dayKg * ratio * Math.max(dayMonthShare, 0.01)),
|
|
dayFee: Math.round(dayAmt > 0 ? dayAmt : base.dayFee * ratio * Math.max(dayMonthShare, 0.01)),
|
|
dayMonthPct: round2((dayMonthShare || base.dayMonthPct / 100) * 100),
|
|
profitRatePct: incomeWan > 0 ? round2((profitWan / incomeWan) * 100) : 0,
|
|
};
|
|
}
|
|
|