diff --git a/backend/app/schemas/pl/vehicle_fuel.py b/backend/app/schemas/pl/vehicle_fuel.py index 36f56b21b..092a6078b 100644 --- a/backend/app/schemas/pl/vehicle_fuel.py +++ b/backend/app/schemas/pl/vehicle_fuel.py @@ -22,7 +22,7 @@ class VehicleFuelRegisterCreate(BaseModel): register_date: date = Field(..., alias="registerDate") registrant_id: Optional[str] = Field(default=None, alias="registrantId") license_plate: str = Field(..., alias="licensePlate") - odometer_km: Optional[Decimal] = Field(default=None, alias="odometerKm") + odometer_km: Decimal = Field(..., alias="odometerKm") fuel_liters: Decimal = Field(..., alias="fuelLiters") attachment_urls: Optional[str] = Field(default=None, alias="attachmentUrls") remark: Optional[str] = None @@ -45,8 +45,10 @@ class VehicleFuelRegisterCreate(BaseModel): @field_validator("odometer_km") @classmethod - def validate_odometer_km(cls, v: Optional[Decimal]) -> Optional[Decimal]: - if v is not None and v < 0: + def validate_odometer_km(cls, v: Decimal) -> Decimal: + if v is None: + raise ValueError("期初公里数不能为空") + if v < 0: raise ValueError("期初公里数不能为负数") return v diff --git a/backend/app/services/pl_vehicle_fuel_service.py b/backend/app/services/pl_vehicle_fuel_service.py index 059a51145..3378633f4 100644 --- a/backend/app/services/pl_vehicle_fuel_service.py +++ b/backend/app/services/pl_vehicle_fuel_service.py @@ -168,6 +168,15 @@ def to_read( ) +def fuel_register_list_order(): + """列表与导出:按加油日期倒序;同一天再按创建时间、主键倒序。""" + return ( + PlVehicleFuelRegister.register_date.desc(), + PlVehicleFuelRegister.create_time.desc(), + PlVehicleFuelRegister.id.desc(), + ) + + def list_registers( db: Session, tenant_no: str, @@ -260,11 +269,7 @@ def list_registers( cap = int(max_page_size) if max_page_size else 500 page_size = min(max(1, page_size), cap) rows = ( - # 与 seqNo 倒序一致:序号大的在前(create_time ASC,id ASC → seqNo;列表取反) - query.order_by( - PlVehicleFuelRegister.create_time.desc(), - PlVehicleFuelRegister.id.desc(), - ) + query.order_by(*fuel_register_list_order()) .offset((page - 1) * page_size) .limit(page_size) .all() diff --git a/backend/tests/test_pl_vehicle_fuel_list_filters.py b/backend/tests/test_pl_vehicle_fuel_list_filters.py index a0f8e07bf..22024c912 100644 --- a/backend/tests/test_pl_vehicle_fuel_list_filters.py +++ b/backend/tests/test_pl_vehicle_fuel_list_filters.py @@ -47,3 +47,11 @@ def test_fuel_register_filters(): def test_empty(): assert build_fuel_register_column_filters() == [] + + +def test_fuel_register_list_order_by_register_date_desc(): + from app.services.pl_vehicle_fuel_service import fuel_register_list_order + + compiled = " ".join(_compile(clause) for clause in fuel_register_list_order()) + assert compiled.index("register_date desc") < compiled.index("create_time desc") + assert compiled.index("create_time desc") < compiled.index("pl_vehicle_fuel_register.id desc") diff --git a/backend/tests/test_vehicle_fuel_schema.py b/backend/tests/test_vehicle_fuel_schema.py index 6408b58bf..c0d04dbbd 100644 --- a/backend/tests/test_vehicle_fuel_schema.py +++ b/backend/tests/test_vehicle_fuel_schema.py @@ -54,9 +54,36 @@ def test_create_accepts_shanxia_factory(): "registerDate": "2026-09-12", "licensePlate": "皖A12345", "fuelLiters": "1176", + "odometerKm": "0", } ) assert body.fuel_category == "shanxia_factory" + assert body.odometer_km == Decimal("0") + + +def test_create_rejects_missing_odometer(): + with pytest.raises(ValidationError): + VehicleFuelRegisterCreate.model_validate( + { + "fuelCategory": "bailian", + "registerDate": "2026-09-12", + "licensePlate": "皖A12345", + "fuelLiters": "10", + } + ) + + +def test_create_rejects_negative_odometer(): + with pytest.raises(ValidationError): + VehicleFuelRegisterCreate.model_validate( + { + "fuelCategory": "bailian", + "registerDate": "2026-09-12", + "licensePlate": "皖A12345", + "fuelLiters": "10", + "odometerKm": "-1", + } + ) def test_calc_fuel_tons(): diff --git a/miniprogram/pages/pl/fuel-register/form.vue b/miniprogram/pages/pl/fuel-register/form.vue index dfe37afbd..e69016e18 100644 --- a/miniprogram/pages/pl/fuel-register/form.vue +++ b/miniprogram/pages/pl/fuel-register/form.vue @@ -80,13 +80,14 @@ - 期初公里数/KM + 期初公里数/KM* @@ -267,6 +268,7 @@ const form = reactive({ const fieldErr = reactive({ registerDate: false, licensePlate: false, + odometerKm: false, fuelLiters: false }) @@ -334,6 +336,7 @@ function onDateChange(e) { function onOdometerInput(e) { form.odometerKm = e.detail.value + fieldErr.odometerKm = false } function onLitersInput(e) { @@ -439,6 +442,7 @@ function previewAt(idx) { function validate() { fieldErr.registerDate = false fieldErr.licensePlate = false + fieldErr.odometerKm = false fieldErr.fuelLiters = false if (!form.fuelCategory) { uni.showToast({ title: '请选择加油类别', icon: 'none' }) @@ -454,6 +458,18 @@ function validate() { uni.showToast({ title: '请选择车牌号', icon: 'none' }) return false } + const odoRaw = String(form.odometerKm ?? '').trim() + if (!odoRaw) { + fieldErr.odometerKm = true + uni.showToast({ title: '请输入期初公里数', icon: 'none' }) + return false + } + const odo = Number(odoRaw) + if (Number.isNaN(odo) || odo < 0) { + fieldErr.odometerKm = true + uni.showToast({ title: '期初公里数须为非负数字', icon: 'none' }) + return false + } const litersRaw = String(form.fuelLiters ?? '').trim() if (!litersRaw) { fieldErr.fuelLiters = true diff --git a/web-admin/src/views/pl/vehicle-fuel-register/FuelRegisterListPage.vue b/web-admin/src/views/pl/vehicle-fuel-register/FuelRegisterListPage.vue index 9959903aa..8e94a7c16 100644 --- a/web-admin/src/views/pl/vehicle-fuel-register/FuelRegisterListPage.vue +++ b/web-admin/src/views/pl/vehicle-fuel-register/FuelRegisterListPage.vue @@ -2,16 +2,7 @@ - + 待开发 @@ -50,9 +41,11 @@ - + fuelStatsRowRef?.resync?.()" > - + + 已选 {{ selectedRows.length }} 项 @@ -503,7 +506,10 @@ import { overflowEditItem, overflowExportPlaceholder } from '@/utils/list-toolbar-overflow-items' +import ListColumnStatsRow from '@/components/business/list-column-stats-row/index.vue' +import type { StatsRowColumn } from '@/utils/list-column-stats' import { usePersistedListColumns } from '@/composables/usePersistedListColumns' +import { useListTableViewportHeight } from '@/composables/useListTableViewportHeight' import { useServerColumnFilters } from '@/composables/useServerColumnFilters' import { plVehicleFuelRegisterColumnFilterDefs } from '@/views/pl/vehicle-fuel-register/columnFilterDefs' import { @@ -521,10 +527,8 @@ import { vehicleFuelRegisterApi, type PlanDocumentAttachmentItem, type Vehicle, - type VehicleFuelPlateMonthItem, type VehicleFuelRegister } from '@/api/pl' -import FuelPlateMonthCards from '@/views/pl/vehicle-fuel-register/FuelPlateMonthCards.vue' import { pickBxData, pickBxPageResult } from '@/utils/http/bx' import { fetchAllPagedRecords } from '@/utils/fetch-paged-list' import { fetchUserOptions } from '@/api/admin' @@ -556,8 +560,6 @@ const props = withDefaults( ) const isMineMode = computed(() => props.mode === 'mine') -/** 加油信息汇总 — 列表上方按车当月/当季百公里油耗卡片 */ -const SHOW_FUEL_PLATE_MONTH_CARDS = true const tableKey = computed(() => isMineMode.value ? 'pl-vehicle-fuel-register' : 'pl-vehicle-fuel-summary' ) @@ -661,52 +663,49 @@ const vehicleOptions = ref([]) const vehicleTrailerLookup = computed(() => buildVehicleTrailerLookup(vehicleOptions.value)) const quickSearchKeyword = ref('') -const plateSummaryItems = ref([]) -const plateSummaryLoading = ref(false) -const plateSummaryError = ref('') - -function currentYearMonth() { - const now = new Date() - return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}` -} - -const summaryMonth = ref(currentYearMonth()) - -function handleSummaryMonthChange(month: string) { - summaryMonth.value = month || currentYearMonth() - fetchPlateSummary() -} - -const selectedPlateFromFilter = computed(() => { - const s = filters.licensePlate as { text?: string } | undefined - return String(s?.text || '').trim() +const fuelTableWrapRef = ref(null) +const fuelBelowRef = ref(null) +const fuelTableRef = ref(null) +const fuelStatsRowRef = ref<{ resync?: () => void } | null>(null) +const { tableHeight } = useListTableViewportHeight({ + tableEl: fuelTableWrapRef, + pagerEl: fuelBelowRef, + tableRef: fuelTableRef, + enabled: computed(() => !isMineMode.value) }) -async function fetchPlateSummary() { - if (!SHOW_FUEL_PLATE_MONTH_CARDS || isMineMode.value) return - plateSummaryLoading.value = true - plateSummaryError.value = '' - try { - const res = await vehicleFuelRegisterApi.plateMonthSummary(summaryMonth.value) - const data = (res as { data?: { items?: VehicleFuelPlateMonthItem[] } }).data ?? res - plateSummaryItems.value = (data as { items?: VehicleFuelPlateMonthItem[] }).items || [] - } catch (e: unknown) { - plateSummaryItems.value = [] - plateSummaryError.value = - e && typeof e === 'object' && 'message' in e - ? String((e as { message?: string }).message || '加载车辆汇总失败') - : '加载车辆汇总失败' - } finally { - plateSummaryLoading.value = false +const fuelStatsRowColumns = computed((): StatsRowColumn[] => { + const cols: StatsRowColumn[] = [ + { key: '__selection', interactive: false }, + { key: 'seqNo', interactive: false }, + { key: 'registerNo', interactive: true, prop: 'registerNo' } + ] + for (const def of listColumnDefs) { + if (!isColumnVisible(def.prop)) continue + cols.push({ + key: def.prop, + interactive: def.prop !== 'attachmentUrls', + prop: def.prop + }) } -} + return cols +}) -function handlePlateCardSelect(plate: string | null) { - if (!plate) { - setFilterState('licensePlate', null) - return +function getFuelStatCellValue(row: VehicleFuelRegister, prop: string): unknown { + if (prop === 'fuelCategory') return getPlFuelCategoryLabel(row.fuelCategory) + if (prop === 'licensePlate') { + return formatPlVehiclePlateDisplay(row.licensePlate, undefined, vehicleTrailerLookup.value) } - setFilterState('licensePlate', { text: plate, selected: [] }) + if (prop === 'status') return getPlFuelRegisterStatusLabel(row.status) + if (prop === 'odometerKm') return row.odometerKm + if (prop === 'fuelLiters') return row.fuelLiters + if (prop === 'fuelTons') { + if (row.fuelTons != null) return Number(row.fuelTons) + if (row.fuelLiters != null) return Number(row.fuelLiters) / 1176 + return null + } + if (prop === 'attachmentUrls') return '' + return (row as unknown as Record)[prop] } const pagination = reactive({ page: 1, pageSize: 20, total: 0 }) @@ -734,6 +733,23 @@ const formRules = { registerDate: [{ required: true, message: '请选择日期', trigger: 'change' }], registrantId: [{ required: true, message: '请选择登记人', trigger: 'change' }], licensePlate: [{ required: true, message: '请选择车牌号', trigger: 'change' }], + odometerKm: [ + { + required: true, + trigger: 'change', + validator: (_rule: unknown, value: number | null, callback: (e?: Error) => void) => { + if (value === null || value === undefined || Number.isNaN(Number(value))) { + callback(new Error('请输入期初公里数')) + return + } + if (Number(value) < 0) { + callback(new Error('期初公里数不能为负数')) + return + } + callback() + } + } + ], fuelLiters: [{ required: true, message: '请输入加油升数', trigger: 'blur' }] } @@ -871,7 +887,6 @@ async function fetchData() { function handleToolbarRefresh() { fetchData() - fetchPlateSummary() } function handleSearch() { @@ -1074,7 +1089,6 @@ async function handleSubmit() { } dialogVisible.value = false await fetchData() - await fetchPlateSummary() if (editing) await refreshViewIfOpen(formData.id) await promptCopyAfterSave(saved, editing) } finally { @@ -1098,7 +1112,6 @@ async function handleCancel(row: VehicleFuelRegister) { ) ElMessage.success('已作废') fetchData() - fetchPlateSummary() } finally { cancelLoadingMap[row.id] = false } @@ -1136,7 +1149,6 @@ async function handleRestoreMany(rows: VehicleFuelRegister[]) { ElMessage.warning(`成功恢复 ${successCount} 条,${failedCount} 条失败`) } await fetchData() - await fetchPlateSummary() } finally { restoreLoading.value = false for (const row of targets) restoreLoadingMap[row.id] = false @@ -1146,6 +1158,19 @@ async function handleRestoreMany(rows: VehicleFuelRegister[]) { onMounted(async () => { await Promise.all([loadVehicleOptions(), loadUserOptions()]) fetchData() - fetchPlateSummary() }) + +