feat(pl): 调整加油登记列表排序、必填项和顶部汇总区。
按登记日期倒序列表并增加列统计,期初公里数改为必填,车辆统计卡片改为待开发。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
574e2d9b31
commit
af8d4d90fa
6 files changed
+155
-72
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
@@ -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():
|
||||
|
||||
@@ -80,13 +80,14 @@
|
||||
</view>
|
||||
|
||||
<view class="field">
|
||||
<text class="field-label">期初公里数/KM</text>
|
||||
<text class="field-label">期初公里数/KM<text class="req">*</text></text>
|
||||
<input
|
||||
class="form-input"
|
||||
type="digit"
|
||||
:class="{ error: fieldErr.odometerKm }"
|
||||
:value="form.odometerKm"
|
||||
:disabled="readonly"
|
||||
placeholder="选填"
|
||||
placeholder="请输入期初公里数"
|
||||
@input="onOdometerInput"
|
||||
/>
|
||||
</view>
|
||||
@@ -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
|
||||
|
||||
@@ -2,16 +2,7 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<section class="panel">
|
||||
<FuelPlateMonthCards
|
||||
v-if="SHOW_FUEL_PLATE_MONTH_CARDS && !isMineMode"
|
||||
:month="summaryMonth"
|
||||
:items="plateSummaryItems"
|
||||
:loading="plateSummaryLoading"
|
||||
:error="plateSummaryError"
|
||||
:selected-plate="selectedPlateFromFilter"
|
||||
@update:month="handleSummaryMonthChange"
|
||||
@select="handlePlateCardSelect"
|
||||
/>
|
||||
<div v-if="!isMineMode" class="fuel-summary-pending">待开发</div>
|
||||
|
||||
<div class="list-toolbar">
|
||||
<div class="list-toolbar-row">
|
||||
@@ -50,9 +41,11 @@
|
||||
</div>
|
||||
|
||||
<div class="list-body list-wrap">
|
||||
<div class="table-scroll bx-list-table-wrap">
|
||||
<div ref="fuelTableWrapRef" class="table-scroll bx-list-table-wrap">
|
||||
<ListTable
|
||||
ref="fuelTableRef"
|
||||
:table-key="tableKey"
|
||||
:height="isMineMode ? undefined : tableHeight"
|
||||
class="list-table"
|
||||
:data="tableData"
|
||||
v-loading="loading"
|
||||
@@ -60,6 +53,7 @@
|
||||
row-key="id"
|
||||
@selection-change="handleSelectionChange"
|
||||
@row-dblclick="handleView"
|
||||
@header-dragend="() => fuelStatsRowRef?.resync?.()"
|
||||
>
|
||||
<el-table-column type="selection" width="55" align="center" />
|
||||
<el-table-column
|
||||
@@ -319,7 +313,16 @@
|
||||
</el-table-column>
|
||||
</ListTable>
|
||||
</div>
|
||||
<div>
|
||||
<div ref="fuelBelowRef">
|
||||
<ListColumnStatsRow
|
||||
v-if="!isMineMode"
|
||||
ref="fuelStatsRowRef"
|
||||
storage-key="pl-vehicle-fuel-summary-column-stats"
|
||||
:columns="fuelStatsRowColumns"
|
||||
:data="tableData"
|
||||
:table-wrap-el="fuelTableWrapRef"
|
||||
:get-cell-value="getFuelStatCellValue"
|
||||
/>
|
||||
<div class="pagination list-pager-bar">
|
||||
<div class="list-pager-selected">
|
||||
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
|
||||
@@ -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<Vehicle[]>([])
|
||||
const vehicleTrailerLookup = computed(() => buildVehicleTrailerLookup(vehicleOptions.value))
|
||||
const quickSearchKeyword = ref('')
|
||||
|
||||
const plateSummaryItems = ref<VehicleFuelPlateMonthItem[]>([])
|
||||
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<HTMLElement | null>(null)
|
||||
const fuelBelowRef = ref<HTMLElement | null>(null)
|
||||
const fuelTableRef = ref<any>(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<string, unknown>)[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()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.fuel-summary-pending {
|
||||
margin: 16px 16px 0;
|
||||
padding: 28px 16px;
|
||||
border: 1px dashed var(--mint-border, #e2e8f0);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user