Pārlūkot izejas kodu

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>
zlwu 2 mēneši atpakaļ
vecāks
revīzija
5f2a6f2338

+ 66 - 0
backend/app/api/db/public_vehicles.py

@@ -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
backend/app/core/config.py

@@ -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 - 0
backend/app/main.py

@@ -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)

+ 11 - 0
backend/tests/test_public_db_vehicle_plates.py

@@ -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
mobile-h5/src/views/inventory/fill.vue

@@ -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
mobile-h5/src/views/inventory/plan.vue

@@ -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 {