feat: public vehicle plate options for H5 fill pages; select-only plates

Add GET /api/public/db/vehicle-plates for unauthenticated fill/plan pages and remove manual plate entry plus auto-create.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-03 22:44:01 +08:00
1 parent db06fea45a
commit 5f2a6f2338
6 files changed
+145 -21

No files matched your search

+66
View File
@@ -0,0 +1,66 @@
"""地磅公共只读:免登车牌 options(现场填写页)"""
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.models.db_models import DbInboundOutbound, DbPurchasePlan
from app.models.pl_models import PlVehicle
router = APIRouter(prefix="/public/db", tags=["地磅-公共车牌"])
def _resolve_tenant_no(
db: Session,
inventory_id: Optional[str],
plan_id: Optional[str],
) -> str:
if inventory_id:
row = (
db.query(DbInboundOutbound)
.filter(DbInboundOutbound.id == inventory_id, DbInboundOutbound.del_flag == 0)
.first()
)
if not row:
raise HTTPException(status_code=404, detail="出入库单不存在")
return row.tenant_no
if plan_id:
plan = (
db.query(DbPurchasePlan)
.filter(DbPurchasePlan.id == plan_id, DbPurchasePlan.del_flag == 0)
.first()
)
if not plan:
raise HTTPException(status_code=404, detail="计划不存在")
return plan.tenant_no
raise HTTPException(status_code=400, detail="需要提供 inventoryId 或 planId")
@router.get("/vehicle-plates")
def list_public_vehicle_plates(
q: Optional[str] = Query(None),
inventory_id: Optional[str] = Query(None, alias="inventoryId"),
plan_id: Optional[str] = Query(None, alias="planId"),
db: Session = Depends(get_db),
):
"""免登:按出入库/计划解析 tenant_no,返回 pl_vehicle 车牌(self + carrier)。"""
tenant_no = _resolve_tenant_no(db, inventory_id, plan_id)
query = db.query(PlVehicle).filter(
PlVehicle.tenant_no == tenant_no,
PlVehicle.del_flag == 0,
PlVehicle.vehicle_type.in_(["self", "carrier"]),
)
kw = (q or "").strip()
if kw:
query = query.filter(PlVehicle.license_plate.contains(kw))
rows = (
query.order_by(PlVehicle.create_time.desc()).limit(100).all()
)
return [
{"licensePlate": r.license_plate, "vehicleType": r.vehicle_type}
for r in rows
if r.license_plate
]
+2 -1
View File
@@ -177,7 +177,8 @@ class Settings(BaseSettings):
"/api/public/pl/site,"
"/api/public/pl/inbound,"
"/api/public/pl/outbound,"
"/api/public/db/inventory"
"/api/public/db/inventory,"
"/api/public/db/vehicle-plates"
)
class Config:
+2
View File
@@ -37,6 +37,7 @@ from app.api.pl.public_io_order import (
outbound_public_router as public_pl_outbound_router,
)
from app.api.db.public_inventory import router as public_db_inventory_router
from app.api.db.public_vehicles import router as public_db_vehicles_router
@asynccontextmanager
@@ -159,6 +160,7 @@ app.include_router(public_pl_site_router, prefix=settings.API_V1_PREFIX)
app.include_router(public_pl_inbound_router, prefix=settings.API_V1_PREFIX)
app.include_router(public_pl_outbound_router, prefix=settings.API_V1_PREFIX)
app.include_router(public_db_inventory_router, prefix=settings.API_V1_PREFIX)
app.include_router(public_db_vehicles_router, prefix=settings.API_V1_PREFIX)
app.include_router(menu_router, prefix=settings.API_V1_PREFIX)
app.include_router(admin_router, prefix=settings.API_V1_PREFIX)
app.include_router(workflow_router, prefix=settings.API_V1_PREFIX)
@@ -0,0 +1,11 @@
"""免登车牌 options 接口入参校验(不依赖真实 DB 数据)。"""
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_vehicle_plates_requires_inventory_or_plan_id():
resp = client.get("/api/public/db/vehicle-plates")
assert resp.status_code == 400
+17 -13
View File
@@ -65,7 +65,12 @@
</div>
<div class="form-item">
<div class="form-label">车牌号 <span class="required">*</span></div>
<van-field v-model="formData.vehicleNo" placeholder="请输入车牌号" />
<div class="form-input" @click="showVehiclePicker = true">
<span :class="{ placeholder: !formData.vehicleNo }">
{{ formData.vehicleNo || '请选择车牌号' }}
</span>
<van-icon name="arrow" class="arrow-icon" />
</div>
</div>
<div class="form-item">
<div class="form-label">备注</div>
@@ -87,6 +92,12 @@
</template>
<van-calendar v-model:show="showDatePicker" @confirm="onDateConfirm" />
<DbVehiclePicker
v-model:show="showVehiclePicker"
public-mode
:inventory-id="String(route.params.id || '')"
@select="onVehicleSelect"
/>
</div>
</template>
@@ -95,7 +106,7 @@ import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { showToast } from 'vant'
import { inventoryApi, type InventoryRecord, type InventoryUpdate } from '@/api/inventoryApi'
import { vehicleApi } from '@/api/masterDataApi'
import DbVehiclePicker from '@/components/pickers/DbVehiclePicker.vue'
import dayjs from 'dayjs'
const route = useRoute()
@@ -103,6 +114,7 @@ const loading = ref(true)
const saving = ref(false)
const record = ref<InventoryRecord | null>(null)
const showDatePicker = ref(false)
const showVehiclePicker = ref(false)
const formData = reactive<InventoryUpdate>({
date: '',
@@ -131,7 +143,7 @@ const loadData = async () => {
if (!id) return
loading.value = true
try {
record.value = await inventoryApi.get(Number(id))
record.value = await inventoryApi.get(String(id))
formData.date = record.value.date || ''
formData.quantity = record.value.quantity
formData.vehicleNo = record.value.vehicleNo || ''
@@ -149,14 +161,8 @@ const onDateConfirm = (value: Date | Date[]) => {
showDatePicker.value = false
}
// 保存前确保车牌号已写入车牌号表(新车牌则先创建,与 Web 行为一致)
const ensureVehicleExists = async (vehicleNo: string) => {
if (!vehicleNo?.trim()) return
try {
await vehicleApi.create({ vehicleNo: vehicleNo.trim(), remarks: null })
} catch (err: any) {
if (err.response?.status !== 409) throw err
}
const onVehicleSelect = (licensePlate: string) => {
formData.vehicleNo = licensePlate
}
const handleSubmit = async () => {
@@ -170,12 +176,10 @@ const handleSubmit = async () => {
if (!record.value) return
saving.value = true
try {
await ensureVehicleExists(vehicle)
await inventoryApi.update(record.value.id, {
date,
quantity: Number(qty),
vehicleNo: vehicle,
// 备注清空时传 null,否则后端不会更新该字段
remarks: formData.remarks === '' ? null : (formData.remarks || undefined)
})
showToast({ type: 'success', message: '保存成功' })
+47 -7
View File
@@ -174,10 +174,12 @@
</div>
<div class="form-item">
<div class="form-label">车牌号 *</div>
<van-field
v-model="createForm.vehicleNo"
placeholder="请输入车牌号"
/>
<div class="form-input" @click="showVehiclePicker = true">
<span :class="{ placeholder: !createForm.vehicleNo }">
{{ createForm.vehicleNo || '请选择车牌号' }}
</span>
<van-icon name="arrow" class="arrow-icon" />
</div>
</div>
<div class="form-item">
<div class="form-label">备注</div>
@@ -270,6 +272,13 @@
<div class="copy-overlay__text">图片生成中...</div>
</div>
</van-overlay>
<DbVehiclePicker
v-model:show="showVehiclePicker"
public-mode
:plan-id="String(route.params.id || '')"
@select="onVehicleSelect"
/>
</div>
</template>
@@ -280,6 +289,7 @@ import dayjs from 'dayjs'
import { showToast } from 'vant'
import { planApi, type ThirdPartyPlanPageData } from '@/api/plansApi'
import { inventoryApi, type InventoryCreate, type InventoryRecord } from '@/api/inventoryApi'
import DbVehiclePicker from '@/components/pickers/DbVehiclePicker.vue'
import { copyImageBlobToClipboard } from '@/utils/utils'
import html2canvas from 'html2canvas'
@@ -293,6 +303,7 @@ const todayStr = dayjs().format('YYYY-MM-DD')
const createDialogOpen = ref(false)
const creating = ref(false)
const showVehiclePicker = ref(false)
const createForm = reactive<Pick<InventoryCreate, 'quantity' | 'vehicleNo' | 'remarks'>>({
quantity: 0,
vehicleNo: '',
@@ -310,7 +321,7 @@ const copyingImage = ref(false)
const captureInventory = ref<InventoryRecord | null>(null)
const captureRef = ref<HTMLElement | null>(null)
const getQrImageUrl = (inventoryId: number) =>
const getQrImageUrl = (inventoryId: string) =>
`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(
String(inventoryId)
)}`
@@ -321,7 +332,7 @@ const qrImageSrc = computed(() => {
})
const loadPage = async () => {
const id = Number(route.params.id)
const id = String(route.params.id || '')
if (!id) {
errorMessage.value = '链接无效:缺少计划ID'
loading.value = false
@@ -346,6 +357,10 @@ const openCreateDialog = () => {
createDialogOpen.value = true
}
const onVehicleSelect = (licensePlate: string) => {
createForm.vehicleNo = licensePlate
}
const handleCreateSubmit = async () => {
if (!pageData.value) return
const qty = createForm.quantity
@@ -354,6 +369,10 @@ const handleCreateSubmit = async () => {
showToast({ type: 'fail', message: '请填写数量和车牌号' })
return
}
if (!pageData.value.plan.productId) {
showToast({ type: 'fail', message: '计划缺少产品,无法创建出入库单' })
return
}
creating.value = true
try {
const p = pageData.value.plan
@@ -361,7 +380,7 @@ const handleCreateSubmit = async () => {
date: todayStr,
partnerCompany: p.customerName || '待填写',
businessType: p.businessType,
product: p.product,
productId: p.productId,
quantity: Number(qty),
planNo: p.planNo,
vehicleNo: vehicle,
@@ -586,6 +605,27 @@ onMounted(() => {
background: #f9fafb;
border: 1px solid #e5e7eb;
}
.form-input {
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 10px 12px;
font-size: 14px;
color: #111827;
display: flex;
align-items: center;
justify-content: space-between;
min-height: 40px;
.placeholder {
color: #9ca3af;
}
.arrow-icon {
color: #9ca3af;
}
}
}
.dialog-actions {