From a65de1d52f0a47d453eb8000d2ff7d9a4e686b76 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Fri, 17 Jul 2026 10:39:26 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=A0=A1=E5=87=86=E9=A6=96=E9=A1=B5?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E4=B8=8E=E5=8E=8B=E7=BC=A9=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../statisticsController.dashboard.test.ts | 177 ++++++++++ backend/src/controllers/mountController.ts | 7 +- .../src/controllers/statisticsController.ts | 324 +++++++++--------- .../__tests__/assetValue.recursion.test.ts | 17 +- .../__tests__/dashboardStatistics.test.ts | 66 ++++ backend/src/utils/assetValue.ts | 13 +- backend/src/utils/dashboardStatistics.ts | 136 ++++++++ frontend/src/style.css | 56 +-- frontend/src/types/index.ts | 16 + frontend/src/views/dashboard/Dashboard.vue | 295 ++++++++++------ .../src/views/equipment/EquipmentList.vue | 106 +----- frontend/src/views/statistics/Statistics.vue | 77 +++-- 12 files changed, 867 insertions(+), 423 deletions(-) create mode 100644 backend/src/controllers/__tests__/statisticsController.dashboard.test.ts create mode 100644 backend/src/utils/__tests__/dashboardStatistics.test.ts create mode 100644 backend/src/utils/dashboardStatistics.ts diff --git a/backend/src/controllers/__tests__/statisticsController.dashboard.test.ts b/backend/src/controllers/__tests__/statisticsController.dashboard.test.ts new file mode 100644 index 0000000..1571e10 --- /dev/null +++ b/backend/src/controllers/__tests__/statisticsController.dashboard.test.ts @@ -0,0 +1,177 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import prisma from '../../utils/prisma' +import { getDashboardStats, getValueStatistics } from '../statisticsController' + +test('首页统计应排除已报废库存、完整覆盖在册状态并使用上海自然日', async () => { + const originalAssetTypeFindMany = prisma.assetType.findMany + const originalEquipmentGroupBy = prisma.equipment.groupBy + const originalEquipmentFindMany = prisma.equipment.findMany + const originalEquipmentAggregate = prisma.equipment.aggregate + const originalEquipmentCount = prisma.equipment.count + const originalBorrowRecordFindMany = prisma.borrowRecord.findMany + const borrowRanges: any[] = [] + + ;(prisma.assetType.findMany as any) = async () => [{ name: '内存条' }] + ;(prisma.equipment.groupBy as any) = async (args: any) => { + if (args.by.length === 1 && args.by[0] === 'status') { + return [ + { status: '可用', _sum: { total_quantity: 1122, remaining_quantity: 1122, total_value: 424805, borrowed_value: 0, available_value: 424805 } }, + { status: '已借出', _sum: { total_quantity: 4507, remaining_quantity: 0, total_value: 2848258.44, borrowed_value: 2848258.44, available_value: 0 } }, + { status: '待报废', _sum: { total_quantity: 1, remaining_quantity: 1, total_value: 0, borrowed_value: 0, available_value: 0 } }, + { status: '已报废', _sum: { total_quantity: 127, remaining_quantity: 127, total_value: 83257, borrowed_value: 0, available_value: 0 } } + ] + } + if (args.by.includes('status')) { + return [ + { equipment_type: '显示器', status: '可用', _sum: { total_quantity: 10, remaining_quantity: 10 } }, + { equipment_type: '显示器', status: '待报废', _sum: { total_quantity: 1, remaining_quantity: 1 } } + ] + } + return [{ equipment_type: '显示器', _sum: { remaining_quantity: 8 } }] + } + ;(prisma.equipment.findMany as any) = async () => [ + { + status: '可用', + total_value: 424805, + borrowed_value: 0, + available_value: 424805, + total_value_with_children: 0 + }, + { + status: '已借出', + total_value: 2848998.44, + borrowed_value: 2848998.44, + available_value: 0, + total_value_with_children: 0 + } + ] + ;(prisma.equipment.aggregate as any) = async () => ({ + _count: { id: 2057 }, + _sum: { total_quantity: 2057 } + }) + ;(prisma.equipment.count as any) = async () => 3 + ;(prisma.borrowRecord.findMany as any) = async (args: any) => { + borrowRanges.push(args.where.borrow_time || args.where.return_time) + return args.where.borrow_time + ? [{ children_snapshot: '[{"unit_price":20}]', equipment: { unit_price: 100 } }] + : [] + } + + const state = { status: 200, body: null as any } + const response = { + status(code: number) { + state.status = code + return this + }, + json(body: any) { + state.body = body + return this + } + } + + try { + await getDashboardStats({} as any, response as any) + + assert.equal(state.status, 200) + assert.equal(state.body.data.total_equipment, 5630) + assert.equal(state.body.data.root_asset_records, 2057) + assert.equal(state.body.data.accessory_equipment, 3573) + assert.equal(state.body.data.available_equipment, 1122) + assert.equal(state.body.data.borrowed_equipment, 4507) + assert.equal(state.body.data.pending_disposal_equipment, 1) + assert.equal(state.body.data.disposed_equipment, 127) + assert.equal(state.body.data.borrowed_without_active_record, 3) + assert.equal(state.body.data.today_borrow, 1) + assert.equal(state.body.data.today_return, 0) + assert.equal(state.body.data.today_borrow_value, 120) + assert.equal(state.body.data.business_timezone, 'Asia/Shanghai') + assert.equal(state.body.data.inventory_scope, 'current_excluding_disposed') + assert.equal(state.body.data.total_value, 3273063.44) + assert.equal(state.body.data.borrowed_value, 2848258.44) + assert.equal( + state.body.data.available_equipment + + state.body.data.borrowed_equipment + + state.body.data.maintenance_equipment + + state.body.data.pending_disposal_equipment + + state.body.data.damaged_equipment + + state.body.data.other_equipment, + state.body.data.total_equipment + ) + assert.ok(Math.abs(state.body.data.usage_rate - 80.06750755018653) < 0.000001) + assert.ok(Math.abs(state.body.data.value_usage_rate - 87.021180377732) < 0.000001) + assert.equal(borrowRanges.length, 2) + for (const range of borrowRanges) { + assert.equal(range.gte.getUTCHours(), 16) + assert.equal(range.lt.getTime() - range.gte.getTime(), 24 * 60 * 60 * 1000) + } + } finally { + ;(prisma.assetType.findMany as any) = originalAssetTypeFindMany + ;(prisma.equipment.groupBy as any) = originalEquipmentGroupBy + ;(prisma.equipment.findMany as any) = originalEquipmentFindMany + ;(prisma.equipment.aggregate as any) = originalEquipmentAggregate + ;(prisma.equipment.count as any) = originalEquipmentCount + ;(prisma.borrowRecord.findMany as any) = originalBorrowRecordFindMany + } +}) + +test('价值统计应按当前资产本体汇总并忽略已报废和过期组合金额缓存', async () => { + const originalEquipmentFindMany = prisma.equipment.findMany + let capturedWhere: any + ;(prisma.equipment.findMany as any) = async (args: any) => { + capturedWhere = args.where + return [ + { + equipment_name: '显示器', + equipment_type: '显示器', + unit_price: 1500, + total_quantity: 1, + total_value: 1500, + borrowed_value: 1500, + available_value: 0, + total_value_with_children: 2240, + status: '已借出' + }, + { + equipment_name: '扩展坞', + equipment_type: '扩展坞', + unit_price: 740, + total_quantity: 1, + total_value: 740, + borrowed_value: 0, + available_value: 740, + total_value_with_children: 0, + status: '可用' + } + ] + } + + const state = { status: 200, body: null as any } + const response = { + status(code: number) { + state.status = code + return this + }, + json(body: any) { + state.body = body + return this + } + } + + try { + await getValueStatistics({ query: { start_date: '2026-07-17', end_date: '2026-07-17' } } as any, response as any) + + assert.equal(capturedWhere.status.not, '已报废') + assert.equal(capturedWhere.created_at.gte.toISOString(), '2026-07-16T16:00:00.000Z') + assert.equal(capturedWhere.created_at.lt.toISOString(), '2026-07-17T16:00:00.000Z') + assert.equal(state.body.data.total_value, 2240) + assert.equal(state.body.data.borrowed_value, 1500) + assert.equal(state.body.data.available_value, 740) + assert.equal(state.body.data.value_by_type.length, 2) + assert.equal(state.body.data.top_equipment[0].item_value, 1500) + assert.equal(state.body.data.top_equipment[0].stock_total_value, 1500) + } finally { + ;(prisma.equipment.findMany as any) = originalEquipmentFindMany + } +}) diff --git a/backend/src/controllers/mountController.ts b/backend/src/controllers/mountController.ts index a5764ab..8d8271f 100644 --- a/backend/src/controllers/mountController.ts +++ b/backend/src/controllers/mountController.ts @@ -5,6 +5,7 @@ import { successResponse, errorResponse } from '../utils/response' import { calculateEquipmentValues, calculateChildrenTotalValueFromRecords, + calculateCurrentTotalValueWithChildren, refreshParentTotalValueWithChildren } from '../utils/assetValue' import { @@ -450,13 +451,17 @@ export const getChildren = async (req: Request, res: Response) => { } const totalChildrenValue = calculateChildrenTotalValueFromRecords(parent.children) + const currentTotalValueWithChildren = calculateCurrentTotalValueWithChildren( + parent.unit_price, + parent.children + ) successResponse( res, { children: parent.children, total_children_value: totalChildrenValue, - total_value_with_children: Number(parent.total_value_with_children) + total_value_with_children: currentTotalValueWithChildren }, '获取成功' ) diff --git a/backend/src/controllers/statisticsController.ts b/backend/src/controllers/statisticsController.ts index e7d77ec..8ab67f3 100644 --- a/backend/src/controllers/statisticsController.ts +++ b/backend/src/controllers/statisticsController.ts @@ -2,35 +2,24 @@ import { Request, Response } from 'express' import { Prisma } from '@prisma/client' import prisma from '../utils/prisma' import { successResponse, errorResponse } from '../utils/response' +import { sumSnapshotChildrenValue } from '../utils/assetValue' import { - getEquipmentDisplayAvailableValue, - getEquipmentDisplayBorrowedValue, - getEquipmentDisplayItemValue, - getEquipmentDisplayTotalValue, - sumSnapshotChildrenValue -} from '../utils/assetValue' - -type DisplayValueRow = { - equipment_type?: string | null - status?: string | null - total_value?: unknown - borrowed_value?: unknown - available_value?: unknown - total_value_with_children?: unknown -} - -type DisplayValueSummary = { - total_value: number - borrowed_value: number - available_value: number - maintenance_value: number -} + DASHBOARD_TIME_ZONE, + getInventoryStatusBreakdown, + getShanghaiDateRange, + getShanghaiDayRange, + summarizeInventoryStatusRows +} from '../utils/dashboardStatistics' type InventoryByTypeRow = { equipment_type: string total_quantity: number available_quantity: number borrowed_quantity: number + maintenance_quantity: number + pending_disposal_quantity: number + damaged_quantity: number + other_quantity: number mountable_available_quantity: number is_child_type: boolean } @@ -51,56 +40,36 @@ const buildEquipmentWhere = (equipmentType?: string, startDate?: string, endDate if (startDate || endDate) { where.created_at = {} if (startDate) { - where.created_at.gte = new Date(startDate) + where.created_at.gte = getShanghaiDateRange(startDate).start } if (endDate) { - const endOfDay = new Date(endDate) - endOfDay.setHours(23, 59, 59, 999) - where.created_at.lte = endOfDay + where.created_at.lt = getShanghaiDateRange(endDate).end + } + if ( + where.created_at.gte instanceof Date && + where.created_at.lt instanceof Date && + where.created_at.gte >= where.created_at.lt + ) { + throw new Error('开始日期不能晚于结束日期') } } return where } -const summarizeDisplayValues = (rows: DisplayValueRow[]): DisplayValueSummary => - rows.reduce( - (summary, row) => { - const totalValue = getEquipmentDisplayTotalValue(row) - summary.total_value += totalValue - summary.borrowed_value += getEquipmentDisplayBorrowedValue(row) - summary.available_value += getEquipmentDisplayAvailableValue(row) - - if (row.status === '维护中') { - summary.maintenance_value += totalValue - } - - return summary - }, - { - total_value: 0, - borrowed_value: 0, - available_value: 0, - maintenance_value: 0 - } - ) - // 获取首页统计数据 export const getDashboardStats = async (req: Request, res: Response) => { try { - const startOfToday = new Date(new Date().setHours(0, 0, 0, 0)) + const generatedAt = new Date() + const todayRange = getShanghaiDayRange(generatedAt) const [ childAssetTypes, - equipmentTotals, - availableInventoryTotals, - borrowableInventoryTotals, - maintenanceTotals, + equipmentStatusTotals, inventoryByType, mountableInventoryByType, - displayValueRows, - todayBorrowCount, - todayReturnCount, + currentRootTotals, + borrowedWithoutActiveRecord, todayBorrowRecords, todayReturnRecords ] = await Promise.all([ @@ -113,42 +82,21 @@ export const getDashboardStats = async (req: Request, res: Response) => { name: true } }), - prisma.equipment.aggregate({ + prisma.equipment.groupBy({ + by: ['status'], _sum: { total_quantity: true, + remaining_quantity: true, total_value: true, borrowed_value: true, available_value: true } }), - prisma.equipment.aggregate({ - where: { - status: '可用' - }, - _sum: { - remaining_quantity: true - } - }), - prisma.equipment.aggregate({ - where: { - status: { - in: ['可用', '已借出'] - } - }, - _sum: { - total_quantity: true, - remaining_quantity: true - } - }), - prisma.equipment.aggregate({ - where: { status: '维护中' }, - _sum: { - total_quantity: true, - total_value: true - } - }), prisma.equipment.groupBy({ by: ['equipment_type', 'status'], + where: { + status: { not: '已报废' } + }, _sum: { total_quantity: true, remaining_quantity: true @@ -171,36 +119,35 @@ export const getDashboardStats = async (req: Request, res: Response) => { remaining_quantity: true } }), - prisma.equipment.findMany({ + prisma.equipment.aggregate({ where: { - parent_id: null + parent_id: null, + status: { not: '已报废' } }, - select: { - status: true, - total_value: true, - borrowed_value: true, - available_value: true, - total_value_with_children: true + _count: { + id: true + }, + _sum: { + total_quantity: true } }), - prisma.borrowRecord.count({ + prisma.equipment.count({ where: { - borrow_time: { - gte: startOfToday - } - } - }), - prisma.borrowRecord.count({ - where: { - return_time: { - gte: startOfToday + parent_id: null, + status: '已借出', + borrow_records: { + none: { + status: '已借出', + return_time: null + } } } }), prisma.borrowRecord.findMany({ where: { borrow_time: { - gte: startOfToday + gte: todayRange.start, + lt: todayRange.end } }, select: { @@ -215,7 +162,8 @@ export const getDashboardStats = async (req: Request, res: Response) => { prisma.borrowRecord.findMany({ where: { return_time: { - gte: startOfToday + gte: todayRange.start, + lt: todayRange.end } }, select: { @@ -229,27 +177,50 @@ export const getDashboardStats = async (req: Request, res: Response) => { }) ]) - const totalEquipmentQuantity = toNumber(equipmentTotals._sum.total_quantity) - const availableEquipmentQuantity = toNumber(availableInventoryTotals._sum.remaining_quantity) - const borrowedEquipmentQuantity = Math.max( - toNumber(borrowableInventoryTotals._sum.total_quantity) - toNumber(borrowableInventoryTotals._sum.remaining_quantity), - 0 + const inventorySummary = summarizeInventoryStatusRows( + equipmentStatusTotals.map((item) => ({ + status: item.status, + total_quantity: item._sum.total_quantity, + remaining_quantity: item._sum.remaining_quantity + })) ) - const maintenanceEquipmentQuantity = toNumber(maintenanceTotals._sum.total_quantity) - const displayValueSummary = summarizeDisplayValues(displayValueRows) - const totalValue = displayValueSummary.total_value - const borrowedValue = displayValueSummary.borrowed_value - const availableValue = displayValueSummary.available_value - const maintenanceValue = displayValueSummary.maintenance_value + const totalEquipmentQuantity = inventorySummary.current + const availableEquipmentQuantity = inventorySummary.available + const borrowedEquipmentQuantity = inventorySummary.borrowed + const maintenanceEquipmentQuantity = inventorySummary.maintenance + const rootEquipmentQuantity = toNumber(currentRootTotals._sum.total_quantity) + const accessoryEquipmentQuantity = Math.max(totalEquipmentQuantity - rootEquipmentQuantity, 0) + const currentValueSummary = equipmentStatusTotals.reduce((summary, item) => { + if (item.status === '已报废') return summary + + const totalValue = toNumber(item._sum.total_value) + summary.total_value += totalValue + summary.borrowed_value += toNumber(item._sum.borrowed_value) + summary.available_value += toNumber(item._sum.available_value) + if (item.status === '维护中') summary.maintenance_value += totalValue + return summary + }, { + total_value: 0, + borrowed_value: 0, + available_value: 0, + maintenance_value: 0 + }) + const totalValue = currentValueSummary.total_value + const borrowedValue = currentValueSummary.borrowed_value + const availableValue = currentValueSummary.available_value + const maintenanceValue = currentValueSummary.maintenance_value const todayBorrowValue = todayBorrowRecords.reduce((sum, record) => { return sum + toNumber(record.equipment?.unit_price) + sumSnapshotChildrenValue(record.children_snapshot) }, 0) const todayReturnValue = todayReturnRecords.reduce((sum, record) => { return sum + toNumber(record.equipment?.unit_price) + sumSnapshotChildrenValue(record.children_snapshot) }, 0) - const usageRate = - totalEquipmentQuantity > 0 ? (borrowedEquipmentQuantity / totalEquipmentQuantity) * 100 : 0 - const valueUsageRate = totalValue > 0 ? (borrowedValue / totalValue) * 100 : 0 + const operationalEquipmentQuantity = availableEquipmentQuantity + borrowedEquipmentQuantity + const operationalValue = availableValue + borrowedValue + const usageRate = operationalEquipmentQuantity > 0 + ? (borrowedEquipmentQuantity / operationalEquipmentQuantity) * 100 + : 0 + const valueUsageRate = operationalValue > 0 ? (borrowedValue / operationalValue) * 100 : 0 const childTypeNames = new Set(childAssetTypes.map((item) => item.name)) const mountableInventoryByTypeMap = mountableInventoryByType.reduce((map, item) => { map.set(item.equipment_type || '其他', toNumber(item._sum.remaining_quantity)) @@ -258,12 +229,20 @@ export const getDashboardStats = async (req: Request, res: Response) => { successResponse(res, { total_equipment: totalEquipmentQuantity, + root_asset_records: toNumber(currentRootTotals._count.id), + root_equipment: rootEquipmentQuantity, + accessory_equipment: accessoryEquipmentQuantity, available_equipment: availableEquipmentQuantity, borrowed_equipment: borrowedEquipmentQuantity, maintenance_equipment: maintenanceEquipmentQuantity, + pending_disposal_equipment: inventorySummary.pendingDisposal, + damaged_equipment: inventorySummary.damaged, + other_equipment: inventorySummary.other, + disposed_equipment: inventorySummary.disposed, + borrowed_without_active_record: borrowedWithoutActiveRecord, usage_rate: usageRate, - today_borrow: todayBorrowCount, - today_return: todayReturnCount, + today_borrow: todayBorrowRecords.length, + today_return: todayReturnRecords.length, total_value: totalValue, borrowed_value: borrowedValue, available_value: availableValue, @@ -271,6 +250,9 @@ export const getDashboardStats = async (req: Request, res: Response) => { value_usage_rate: valueUsageRate, today_borrow_value: todayBorrowValue, today_return_value: todayReturnValue, + generated_at: generatedAt.toISOString(), + business_timezone: DASHBOARD_TIME_ZONE, + inventory_scope: 'current_excluding_disposed', inventory_by_type: Array.from( inventoryByType.reduce((map, item) => { const equipmentType = item.equipment_type || '其他' @@ -279,20 +261,28 @@ export const getDashboardStats = async (req: Request, res: Response) => { total_quantity: 0, available_quantity: 0, borrowed_quantity: 0, + maintenance_quantity: 0, + pending_disposal_quantity: 0, + damaged_quantity: 0, + other_quantity: 0, mountable_available_quantity: mountableInventoryByTypeMap.get(equipmentType) || 0, is_child_type: childTypeNames.has(equipmentType) } const totalQuantity = toNumber(item._sum.total_quantity) const remainingQuantity = toNumber(item._sum.remaining_quantity) + const breakdown = getInventoryStatusBreakdown( + item.status, + totalQuantity, + remainingQuantity + ) - current.total_quantity += totalQuantity - - if (item.status === '可用') { - current.available_quantity += remainingQuantity - current.borrowed_quantity += Math.max(totalQuantity - remainingQuantity, 0) - } else if (item.status === '已借出') { - current.borrowed_quantity += Math.max(totalQuantity - remainingQuantity, 0) - } + current.total_quantity += breakdown.current + current.available_quantity += breakdown.available + current.borrowed_quantity += breakdown.borrowed + current.maintenance_quantity += breakdown.maintenance + current.pending_disposal_quantity += breakdown.pendingDisposal + current.damaged_quantity += breakdown.damaged + current.other_quantity += breakdown.other map.set(equipmentType, current) return map @@ -317,41 +307,40 @@ export const getValueStatistics = async (req: Request, res: Response) => { typeof end_date === 'string' ? end_date : undefined ) - const displayValueWhere: Prisma.EquipmentWhereInput = { + const currentInventoryWhere: Prisma.EquipmentWhereInput = { ...where, - parent_id: null + status: { not: '已报废' } } - const [displayValueRows, topEquipmentRows] = await Promise.all([ - prisma.equipment.findMany({ - where: displayValueWhere, - select: { - equipment_type: true, - status: true, - total_value: true, - borrowed_value: true, - available_value: true, - total_value_with_children: true - } - }), - prisma.equipment.findMany({ - where, - select: { - equipment_name: true, - unit_price: true, - total_quantity: true, - total_value: true, - total_value_with_children: true, - borrowed_value: true, - available_value: true, - status: true - }, - }) - ]) + const equipmentRows = await prisma.equipment.findMany({ + where: currentInventoryWhere, + select: { + equipment_name: true, + equipment_type: true, + unit_price: true, + total_quantity: true, + total_value: true, + borrowed_value: true, + available_value: true, + status: true + } + }) - const summary = summarizeDisplayValues(displayValueRows) + const summary = equipmentRows.reduce((current, item) => { + const totalValue = toNumber(item.total_value) + current.total_value += totalValue + current.borrowed_value += toNumber(item.borrowed_value) + current.available_value += toNumber(item.available_value) + if (item.status === '维护中') current.maintenance_value += totalValue + return current + }, { + total_value: 0, + borrowed_value: 0, + available_value: 0, + maintenance_value: 0 + }) const valueByType = Array.from( - displayValueRows.reduce((map, item) => { + equipmentRows.reduce((map, item) => { const equipmentType = item.equipment_type || '未分类' const current = map.get(equipmentType) || { equipment_type: equipmentType, @@ -360,9 +349,9 @@ export const getValueStatistics = async (req: Request, res: Response) => { available_value: 0 } - current.total_value += getEquipmentDisplayTotalValue(item) - current.borrowed_value += getEquipmentDisplayBorrowedValue(item) - current.available_value += getEquipmentDisplayAvailableValue(item) + current.total_value += toNumber(item.total_value) + current.borrowed_value += toNumber(item.borrowed_value) + current.available_value += toNumber(item.available_value) map.set(equipmentType, current) return map @@ -376,24 +365,29 @@ export const getValueStatistics = async (req: Request, res: Response) => { ) .sort((left, right) => right.total_value - left.total_value) .filter((item) => item.total_value > 0) - const topEquipment = topEquipmentRows + const topEquipment = equipmentRows .map((eq) => ({ equipment_name: eq.equipment_name, quantity: toNumber(eq.total_quantity) || 1, unit_price: toNumber(eq.unit_price), - item_value: getEquipmentDisplayItemValue(eq), - stock_total_value: getEquipmentDisplayTotalValue(eq), - stock_borrowed_value: getEquipmentDisplayBorrowedValue(eq), - stock_available_value: getEquipmentDisplayAvailableValue(eq) + item_value: toNumber(eq.unit_price), + stock_total_value: toNumber(eq.total_value), + stock_borrowed_value: toNumber(eq.borrowed_value), + stock_available_value: toNumber(eq.available_value) })) .sort((a, b) => b.item_value - a.item_value || b.stock_total_value - a.stock_total_value) .slice(0, 10) + const otherValue = Math.max( + summary.total_value - summary.borrowed_value - summary.available_value - summary.maintenance_value, + 0 + ) successResponse(res, { total_value: summary.total_value, borrowed_value: summary.borrowed_value, available_value: summary.available_value, maintenance_value: summary.maintenance_value, + other_value: otherValue, value_by_type: valueByType, top_equipment: topEquipment }, '获取成功') diff --git a/backend/src/utils/__tests__/assetValue.recursion.test.ts b/backend/src/utils/__tests__/assetValue.recursion.test.ts index 8fa100a..10ef989 100644 --- a/backend/src/utils/__tests__/assetValue.recursion.test.ts +++ b/backend/src/utils/__tests__/assetValue.recursion.test.ts @@ -1,7 +1,22 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { refreshParentTotalValueWithChildren, syncMountedChildrenState } from '../assetValue' +import { + calculateCurrentTotalValueWithChildren, + refreshParentTotalValueWithChildren, + syncMountedChildrenState +} from '../assetValue' + +test('当前组合价值只根据实时父子关系计算', () => { + assert.equal(calculateCurrentTotalValueWithChildren(1500, []), 1500) + assert.equal( + calculateCurrentTotalValueWithChildren(1500, [ + { unit_price: 300 }, + { unit_price: '440' } + ]), + 2240 + ) +}) test('syncMountedChildrenState 发现父子环时在写入前中止', async () => { let updateCount = 0 diff --git a/backend/src/utils/__tests__/dashboardStatistics.test.ts b/backend/src/utils/__tests__/dashboardStatistics.test.ts new file mode 100644 index 0000000..652f176 --- /dev/null +++ b/backend/src/utils/__tests__/dashboardStatistics.test.ts @@ -0,0 +1,66 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + getShanghaiDateRange, + getInventoryStatusBreakdown, + getShanghaiDayRange, + summarizeInventoryStatusRows +} from '../dashboardStatistics' + +test('日期筛选应覆盖完整的上海自然日并拒绝不存在的日期', () => { + const range = getShanghaiDateRange('2026-07-17') + assert.equal(range.start.toISOString(), '2026-07-16T16:00:00.000Z') + assert.equal(range.end.toISOString(), '2026-07-17T16:00:00.000Z') + assert.throws(() => getShanghaiDateRange('2026-02-30'), /日期不存在/) + assert.throws(() => getShanghaiDateRange('2026\/07\/17'), /YYYY-MM-DD/) +}) + +test('今日边界应固定按 Asia/Shanghai 计算,不受服务器 UTC 时区影响', () => { + const beforeMidnight = getShanghaiDayRange(new Date('2026-07-16T15:59:59.999Z')) + assert.equal(beforeMidnight.start.toISOString(), '2026-07-15T16:00:00.000Z') + assert.equal(beforeMidnight.end.toISOString(), '2026-07-16T16:00:00.000Z') + + const atMidnight = getShanghaiDayRange(new Date('2026-07-16T16:00:00.000Z')) + assert.equal(atMidnight.start.toISOString(), '2026-07-16T16:00:00.000Z') + assert.equal(atMidnight.end.toISOString(), '2026-07-17T16:00:00.000Z') +}) + +test('可用和已借出记录应按剩余数量拆分,保证在册总量可对账', () => { + assert.deepEqual(getInventoryStatusBreakdown('可用', 10, 4), { + current: 10, + available: 4, + borrowed: 6, + maintenance: 0, + pendingDisposal: 0, + damaged: 0, + other: 0, + disposed: 0 + }) + + const summary = summarizeInventoryStatusRows([ + { status: '可用', total_quantity: 10, remaining_quantity: 4 }, + { status: '已借出', total_quantity: 3, remaining_quantity: 0 }, + { status: '维护中', total_quantity: 2, remaining_quantity: 2 }, + { status: '待报废', total_quantity: 1, remaining_quantity: 1 }, + { status: '已损坏', total_quantity: 1, remaining_quantity: 1 }, + { status: '自定义状态', total_quantity: 1, remaining_quantity: 1 }, + { status: '已报废', total_quantity: 5, remaining_quantity: 5 } + ]) + + assert.deepEqual(summary, { + current: 18, + available: 4, + borrowed: 9, + maintenance: 2, + pendingDisposal: 1, + damaged: 1, + other: 1, + disposed: 5 + }) + assert.equal( + summary.available + summary.borrowed + summary.maintenance + + summary.pendingDisposal + summary.damaged + summary.other, + summary.current + ) +}) diff --git a/backend/src/utils/assetValue.ts b/backend/src/utils/assetValue.ts index 29f3a0a..649741d 100644 --- a/backend/src/utils/assetValue.ts +++ b/backend/src/utils/assetValue.ts @@ -84,6 +84,13 @@ export const calculateChildrenTotalValueFromRecords = ( return children.reduce((sum, child) => sum + toSafeNumber(child.unit_price), 0) } +export const calculateCurrentTotalValueWithChildren = ( + parentUnitPrice: unknown, + children: Array<{ unit_price?: unknown }> +) => { + return toSafeNumber(parentUnitPrice) + calculateChildrenTotalValueFromRecords(children) +} + export const refreshParentTotalValueWithChildren = async (client: EquipmentClient, parentId: number) => { const parent = await client.equipment.findUnique({ where: { id: parentId }, @@ -100,8 +107,10 @@ export const refreshParentTotalValueWithChildren = async (client: EquipmentClien return } - const totalValueWithChildren = - toSafeNumber(parent.unit_price) + calculateChildrenTotalValueFromRecords(parent.children) + const totalValueWithChildren = calculateCurrentTotalValueWithChildren( + parent.unit_price, + parent.children + ) await client.equipment.update({ where: { id: parentId }, diff --git a/backend/src/utils/dashboardStatistics.ts b/backend/src/utils/dashboardStatistics.ts new file mode 100644 index 0000000..615bf8f --- /dev/null +++ b/backend/src/utils/dashboardStatistics.ts @@ -0,0 +1,136 @@ +const SHANGHAI_UTC_OFFSET_MS = 8 * 60 * 60 * 1000 +const DAY_MS = 24 * 60 * 60 * 1000 + +export const DASHBOARD_TIME_ZONE = 'Asia/Shanghai' + +export type InventoryStatusBreakdown = { + current: number + available: number + borrowed: number + maintenance: number + pendingDisposal: number + damaged: number + other: number + disposed: number +} + +export type InventoryStatusTotalRow = { + status?: string | null + total_quantity?: unknown + remaining_quantity?: unknown +} + +const toSafeQuantity = (value: unknown) => { + const parsed = Number(value) + return Number.isFinite(parsed) ? Math.max(0, parsed) : 0 +} + +export const getShanghaiDayRange = (now = new Date()) => { + const shifted = new Date(now.getTime() + SHANGHAI_UTC_OFFSET_MS) + const startTimestamp = Date.UTC( + shifted.getUTCFullYear(), + shifted.getUTCMonth(), + shifted.getUTCDate() + ) - SHANGHAI_UTC_OFFSET_MS + + return { + start: new Date(startTimestamp), + end: new Date(startTimestamp + DAY_MS) + } +} + +export const getShanghaiDateRange = (dateText: string) => { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateText) + if (!match) throw new Error('日期格式必须为 YYYY-MM-DD') + + const year = Number(match[1]) + const month = Number(match[2]) + const day = Number(match[3]) + const calendarTimestamp = Date.UTC(year, month - 1, day) + const calendarDate = new Date(calendarTimestamp) + if ( + calendarDate.getUTCFullYear() !== year || + calendarDate.getUTCMonth() !== month - 1 || + calendarDate.getUTCDate() !== day + ) { + throw new Error('日期不存在') + } + + const startTimestamp = calendarTimestamp - SHANGHAI_UTC_OFFSET_MS + return { + start: new Date(startTimestamp), + end: new Date(startTimestamp + DAY_MS) + } +} + +export const getInventoryStatusBreakdown = ( + status: string | null | undefined, + totalQuantityValue: unknown, + remainingQuantityValue: unknown +): InventoryStatusBreakdown => { + const totalQuantity = toSafeQuantity(totalQuantityValue) + const remainingQuantity = Math.min(toSafeQuantity(remainingQuantityValue), totalQuantity) + const empty: InventoryStatusBreakdown = { + current: 0, + available: 0, + borrowed: 0, + maintenance: 0, + pendingDisposal: 0, + damaged: 0, + other: 0, + disposed: 0 + } + + if (status === '已报废') { + return { ...empty, disposed: totalQuantity } + } + + empty.current = totalQuantity + + if (status === '可用' || status === '已借出') { + empty.available = remainingQuantity + empty.borrowed = Math.max(totalQuantity - remainingQuantity, 0) + } else if (status === '维护中') { + empty.maintenance = totalQuantity + } else if (status === '待报废') { + empty.pendingDisposal = totalQuantity + } else if (status === '已损坏') { + empty.damaged = totalQuantity + } else { + empty.other = totalQuantity + } + + return empty +} + +export const summarizeInventoryStatusRows = ( + rows: InventoryStatusTotalRow[] +): InventoryStatusBreakdown => rows.reduce( + (summary, row) => { + const current = getInventoryStatusBreakdown( + row.status, + row.total_quantity, + row.remaining_quantity + ) + + summary.current += current.current + summary.available += current.available + summary.borrowed += current.borrowed + summary.maintenance += current.maintenance + summary.pendingDisposal += current.pendingDisposal + summary.damaged += current.damaged + summary.other += current.other + summary.disposed += current.disposed + return summary + }, + { + current: 0, + available: 0, + borrowed: 0, + maintenance: 0, + pendingDisposal: 0, + damaged: 0, + other: 0, + disposed: 0 + } +) diff --git a/frontend/src/style.css b/frontend/src/style.css index ce5a065..575e780 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -258,12 +258,12 @@ .page-header { display: flex; flex-direction: column; - gap: var(--space-4); - padding: var(--space-4); + gap: var(--space-3); + padding: var(--space-3); border: 1px solid var(--border-color); - border-radius: var(--radius-xl); + border-radius: var(--radius-lg); background: var(--bg-secondary); - box-shadow: 0 8px 28px rgba(0, 0, 0, 0.12); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.1); } .page-kicker { @@ -275,24 +275,26 @@ } .page-title { - margin-top: var(--space-2); + margin-top: var(--space-1); color: var(--text-primary); - font-size: 24px; + font-size: 22px; font-weight: 600; line-height: 1.2; } .page-meta-grid { display: grid; - gap: var(--space-3); - grid-template-columns: repeat(1, minmax(0, 1fr)); + width: 100%; + gap: var(--space-2); + grid-template-columns: repeat(2, minmax(0, 1fr)); } .page-meta { + min-width: 0; border: 1px solid var(--border-color); - border-radius: var(--radius-lg); + border-radius: var(--radius-md); background: var(--bg-elevated); - padding: var(--space-3); + padding: 8px 10px; } .page-meta__label { @@ -303,16 +305,22 @@ } .page-meta__value { - margin-top: var(--space-1); - font-size: 18px; + margin-top: 2px; + overflow: hidden; + font-size: 16px; font-weight: 600; color: var(--text-primary); + text-overflow: ellipsis; + white-space: nowrap; } .page-meta__subvalue { - margin-top: 2px; - font-size: 11px; + margin-top: 1px; + overflow: hidden; + font-size: 10px; color: var(--text-secondary); + text-overflow: ellipsis; + white-space: nowrap; } .section-title { @@ -749,7 +757,7 @@ } .page-meta-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(112px, 1fr)); } .list-header { @@ -787,14 +795,16 @@ @media (min-width: 1024px) { .page-header { - flex-direction: row; - align-items: flex-start; - justify-content: space-between; - padding: var(--space-6); + display: grid; + grid-template-columns: minmax(160px, auto) minmax(0, 1fr); + align-items: center; + padding: var(--space-4); } .page-meta-grid { - grid-template-columns: repeat(3, minmax(0, 1fr)); + width: min(100%, 640px); + justify-self: end; + grid-template-columns: repeat(auto-fit, minmax(112px, 1fr)); } .filter-bar { @@ -863,7 +873,7 @@ .page-header { padding: var(--space-3); - gap: var(--space-3); + gap: var(--space-2); } .page-title { @@ -875,11 +885,11 @@ } .page-meta { - padding: var(--space-3); + padding: 8px 10px; } .page-meta__value { - font-size: 16px; + font-size: 15px; } .list-header { diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 1859fb6..e008ec8 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -556,9 +556,17 @@ export interface PaginatedResponse { // 统计数据接口 export interface DashboardStats { total_equipment: number + root_asset_records: number + root_equipment: number + accessory_equipment: number available_equipment: number borrowed_equipment: number maintenance_equipment: number + pending_disposal_equipment: number + damaged_equipment: number + other_equipment: number + disposed_equipment: number + borrowed_without_active_record: number usage_rate: number today_borrow: number today_return: number @@ -569,11 +577,18 @@ export interface DashboardStats { value_usage_rate: number today_borrow_value: number today_return_value: number + generated_at: string + business_timezone: 'Asia/Shanghai' + inventory_scope: 'current_excluding_disposed' inventory_by_type: Array<{ equipment_type: string total_quantity: number available_quantity: number borrowed_quantity: number + maintenance_quantity: number + pending_disposal_quantity: number + damaged_quantity: number + other_quantity: number mountable_available_quantity: number is_child_type: boolean }> @@ -585,6 +600,7 @@ export interface ValueStatistics { borrowed_value: number available_value: number maintenance_value: number + other_value: number value_by_type: Array<{ equipment_type: string total_value: number diff --git a/frontend/src/views/dashboard/Dashboard.vue b/frontend/src/views/dashboard/Dashboard.vue index 1a38dc1..94c7876 100644 --- a/frontend/src/views/dashboard/Dashboard.vue +++ b/frontend/src/views/dashboard/Dashboard.vue @@ -1,5 +1,5 @@