Преглед изворни кода

feat(web-admin): driver feedback dialog brand qty entry

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu пре 3 месеци
родитељ
комит
b7236329c2

+ 22 - 6
backend/app/schemas/pl/plan.py

@@ -88,8 +88,16 @@ class PlanProductRead(PlanProductBase):
 
 class PlanBase(BaseModel):
     belongs_company: str = Field(..., alias="belongsCompany", description="归属公司 shanxia-安庆山峡 yundong-安徽云栋")
-    plan_type: str = Field(..., alias="planType", description="单据类型 direct-直销单 inbound-入库单 outbound-出库单")
-    follow_status: str = Field(default="draft", alias="followStatus", description="跟进状态")
+    plan_type: str = Field(
+        default="",
+        alias="planType",
+        description="单据类型 direct-直销单 inbound-入库单 outbound-出库单;未选择可为空",
+    )
+    follow_status: str = Field(
+        default="pending_arrange",
+        alias="followStatus",
+        description="跟进状态 wait_notice/pending_arrange/arranged/completed/cancelled",
+    )
     transport_status: str = Field(default="pending", alias="transportStatus", description="运输安排状态")
     archive_status: str = Field(default="unarchived", alias="archiveStatus", description="归档状态")
     complete_status: str = Field(default="no", alias="completeStatus", description="完成状态")
@@ -130,7 +138,7 @@ class PlanCreate(PlanBase):
     create_date: Optional[date] = Field(
         default=None, alias="createDate", description="创建日期;缺省则服务端用当天"
     )
-    plan_date: date = Field(..., alias="planDate", description="计划日期")
+    plan_date: Optional[date] = Field(default=None, alias="planDate", description="计划日期(非必填)")
     products: Optional[list[PlanProductCreate]] = Field(default=None, description="产品明细列表")
     model_config = {"populate_by_name": True}
 
@@ -199,7 +207,7 @@ class PlanRead(PlanBase, PlanResourceSnapshotMixin):
     id: str
     plan_no: str = Field(alias="planNo")
     create_date: date = Field(alias="createDate")
-    plan_date: date = Field(alias="planDate")
+    plan_date: Optional[date] = Field(default=None, alias="planDate")
     hazard_cert_ready: int = Field(default=0, alias="hazardCertReady", description="危化品证书是否已人工确认齐备")
     hazard_cert_ready_by: Optional[str] = Field(default=None, alias="hazardCertReadyBy", description="危化品证书确认人")
     hazard_cert_ready_time: Optional[datetime] = Field(default=None, alias="hazardCertReadyTime", description="危化品证书确认时间")
@@ -256,7 +264,7 @@ class CreateFromContractRequest(BaseModel):
     create_date: Optional[date] = Field(
         default=None, alias="createDate", description="创建日期;缺省当天"
     )
-    plan_date: date = Field(..., alias="planDate", description="计划日期")
+    plan_date: Optional[date] = Field(default=None, alias="planDate", description="计划日期(非必填)")
     follow_user_id: Optional[str] = Field(default=None, alias="followUserId", description="跟进人ID")
     follow_user_name: Optional[str] = Field(default=None, max_length=100, alias="followUserName", description="跟进人姓名")
     model_config = {"populate_by_name": True}
@@ -332,11 +340,19 @@ class PlanArchiveBody(BaseModel):
     model_config = {"populate_by_name": True}
 
 
+class PlanDriverFeedbackProductItem(BaseModel):
+    plan_product_id: str = Field(alias="planProductId")
+    actual_qty: Decimal = Field(alias="actualQty", ge=0)
+    brand_qtys: Optional[list[PlanArchiveBrandQtyItem]] = Field(default=None, alias="brandQtys")
+    model_config = {"populate_by_name": True}
+
+
 class PlanDriverFeedbackUpdateBody(BaseModel):
-    feedback_qty: Optional[Decimal] = Field(default=None, alias="feedbackQty", ge=0, description="回传数量")
     feedback_remark: Optional[str] = Field(default=None, alias="feedbackRemark", max_length=500, description="回传备注")
     feedback_images: Optional[list[str]] = Field(default=None, alias="feedbackImages", description="回传附件URLs")
     feedback_time: OptionalPlanDateTime = Field(default=None, alias="feedbackTime", description="回传时间")
+    products: Optional[list[PlanDriverFeedbackProductItem]] = None
+    feedback_qty: Optional[Decimal] = Field(default=None, alias="feedbackQty", ge=0, description="回传数量")
     model_config = {"populate_by_name": True}
 
 

+ 24 - 1
backend/app/services/pl_plan_service.py

@@ -32,6 +32,7 @@ from app.schemas.pl.plan import (
     PlanTransportCreateBlock,
 )
 from app.services.pl_brand_archive import persist_plan_product_brand_qtys, validate_brand_qty_rows
+from app.services.pl_feedback_brand_qty import persist_transport_product_feedback
 from app.services.pl_freight_settlement_service import sync_draft_settlement_freight_for_transport
 from app.services.pl_transport_resource_snapshot_service import (
     apply_resource_snapshot_json,
@@ -966,6 +967,7 @@ def create_transport_for_plan(
         driver_id=driver_id,
     )
     plan.transport_status = "arranged"
+    plan.follow_status = "arranged"
     plan.freight_amount = freight_amount or 0
 
     return transport
@@ -1316,7 +1318,28 @@ def apply_plan_driver_feedback(
     if not transport:
         raise HTTPException(status_code=404, detail="运输单不存在")
 
-    if payload.feedback_qty is not None:
+    if payload.products is not None:
+        products_payload = [
+            {
+                "planProductId": item.plan_product_id,
+                "actualQty": item.actual_qty,
+                "brandQtys": [b.model_dump(by_alias=True) for b in (item.brand_qtys or [])],
+            }
+            for item in payload.products
+        ]
+        try:
+            persist_transport_product_feedback(
+                db,
+                tenant_no=tenant_no,
+                transport=transport,
+                plan_id=plan.id,
+                products=products_payload,
+                user_id=user_id,
+                enforce_brand=True,
+            )
+        except ValueError as exc:
+            raise HTTPException(status_code=400, detail=str(exc)) from exc
+    elif payload.feedback_qty is not None:
         transport.feedback_qty = payload.feedback_qty
     if payload.feedback_remark is not None:
         transport.feedback_remark = payload.feedback_remark.strip() if payload.feedback_remark else None

+ 16 - 4
web-admin/src/api/pl/index.ts

@@ -416,7 +416,7 @@ export interface Plan {
   id: string
   planNo: string
   createDate: string
-  planDate: string
+  planDate?: string | null
   belongsCompany: string
   planType: string
   followStatus: string
@@ -509,9 +509,9 @@ export interface PlanTransportCreateBlock {
 export interface PlanCreateFullPayload {
   contractId: string
   createDate: string
-  planDate: string
+  planDate?: string | null
   belongsCompany: string
-  planType: string
+  planType?: string
   contractNo?: string
   supplierId?: string
   supplierName?: string
@@ -591,11 +591,18 @@ export interface PlanArchiveBody {
   freight?: PlanArchiveFreightBlock
 }
 
+export interface PlanDriverFeedbackProductItem {
+  planProductId: string
+  actualQty: number
+  brandQtys?: PlanArchiveBrandQtyItem[]
+}
+
 export interface PlanDriverFeedbackUpdateBody {
   feedbackQty?: number | null
   feedbackRemark?: string | null
   feedbackImages?: string[]
   feedbackTime?: string | null
+  products?: PlanDriverFeedbackProductItem[]
 }
 
 export interface PlanSiteFeedbackUpdateBody {
@@ -626,6 +633,9 @@ export const planApi = {
   setHazardCertReady: (id: string, ready: boolean) =>
     request.patch<Plan>(`/transport/plan/${id}/hazard-cert-ready`, { ready }),
   cancel: (id: string) => request.post<Plan>(`/transport/plan/${id}/cancel`),
+  waitNotice: (id: string) => request.post<Plan>(`/transport/plan/${id}/wait-notice`),
+  cancelWaitNotice: (id: string) =>
+    request.post<Plan>(`/transport/plan/${id}/cancel-wait-notice`),
   getArrangeInfo: (id: string) => request.get<PlanArrangeInfo>(`/transport/plan/${id}/arrange-info`),
   archive: (id: string, data?: PlanArchiveBody) =>
     request.post<Plan>(`/transport/plan/${id}/archive`, data),
@@ -636,7 +646,7 @@ export const planApi = {
   delete: (id: string) => request.delete(`/transport/plan/${id}`),
   createFromContract: (data: {
     contractId: string
-    planDate: string
+    planDate?: string | null
     createDate?: string
     followUserId?: string
     followUserName?: string
@@ -776,6 +786,7 @@ export interface PlIoOrderItem {
   productNo?: string
   productName?: string
   brandSummary?: string
+  brandName?: string
   specification?: string
   unit?: string
   unitLabel?: string
@@ -783,6 +794,7 @@ export interface PlIoOrderItem {
   actualQty?: number
   qtySource?: string
   packageSpec?: string
+  deliveryMethod?: string
   remark?: string
 }
 

+ 395 - 57
web-admin/src/components/business/pl-plan-driver-feedback-dialog/index.vue

@@ -3,7 +3,7 @@
   <el-dialog
     v-model="innerVisible"
     :title="dialogTitle"
-    width="640px"
+    width="720px"
     align-center
     destroy-on-close
     append-to-body
@@ -14,50 +14,90 @@
     @closed="handleClosed"
     @opened="handleOpened"
   >
-    <el-form
-      ref="formRef"
-      :model="formData"
-      label-position="top"
-      class="form-grid-2"
-    >
-      <el-form-item label="回传时间">
-        <el-date-picker
-          v-model="formData.feedbackTime"
-          type="datetime"
-          value-format="YYYY-MM-DD HH:mm:ss"
-          placeholder="不填则保存为当前时间"
-          style="width: 100%"
-          clearable
-        />
-      </el-form-item>
-      <el-form-item label="回传数量">
-        <el-input-number
-          v-model="formData.feedbackQty"
-          :min="0"
-          :precision="3"
-          placeholder="回传数量"
-          style="width: 100%"
-        />
-      </el-form-item>
-      <el-form-item label="回传备注" class="form-col-span-2">
-        <el-input
-          v-model="formData.feedbackRemark"
-          type="textarea"
-          :rows="3"
-          maxlength="500"
-          show-word-limit
-          placeholder="回传备注"
-        />
-      </el-form-item>
-      <el-form-item label="回传附件" class="form-col-span-2">
-        <AttachmentUploadField
-          v-model="formData.feedbackImages"
-          folder="pl/transport/feedback"
-          accept="image/*,video/*,.pdf"
-          :limit="15"
-        />
-      </el-form-item>
-    </el-form>
+    <div v-loading="loading" class="pl-driver-feedback-form">
+      <el-form
+        ref="formRef"
+        :model="formData"
+        label-position="top"
+        class="form-grid-2"
+      >
+        <el-form-item label="回传时间">
+          <el-date-picker
+            v-model="formData.feedbackTime"
+            type="datetime"
+            value-format="YYYY-MM-DD HH:mm:ss"
+            placeholder="不填则保存为当前时间"
+            style="width: 100%"
+            clearable
+          />
+        </el-form-item>
+        <el-form-item label="回传备注" class="form-col-span-2">
+          <el-input
+            v-model="formData.feedbackRemark"
+            type="textarea"
+            :rows="3"
+            maxlength="500"
+            show-word-limit
+            placeholder="回传备注"
+          />
+        </el-form-item>
+        <el-form-item label="回传附件" class="form-col-span-2">
+          <AttachmentUploadField
+            v-model="formData.feedbackImages"
+            folder="pl/transport/feedback"
+            accept="image/*,video/*,.pdf"
+            :limit="15"
+          />
+        </el-form-item>
+      </el-form>
+
+      <section class="pl-driver-feedback-products">
+        <h4 class="pl-driver-feedback-products__title">产品回传数量</h4>
+        <p v-if="!productRows.length && !loading" class="pl-driver-feedback-products__empty">
+          暂无产品明细
+        </p>
+        <div
+          v-for="(row, idx) in productRows"
+          :key="row.planProductId"
+          class="pl-driver-feedback-product"
+        >
+          <div class="pl-driver-feedback-product__head">
+            <span class="pl-driver-feedback-product__name">{{ row.productName || '产品' }}</span>
+            <span class="pl-driver-feedback-product__badge">
+              产品 {{ idx + 1 }}/{{ productRows.length }}
+            </span>
+          </div>
+          <div class="pl-driver-feedback-product__actual">
+            <span class="pl-driver-feedback-product__actual-label">实际数量</span>
+            <el-input-number
+              v-model="row.actualQty"
+              :min="0"
+              :precision="3"
+              :controls="false"
+              :disabled="Boolean(row.brandMode)"
+              class="pl-driver-feedback-product__actual-input"
+              @change="() => onActualQtyChange(row)"
+            />
+            <span class="pl-driver-feedback-product__unit">{{ row.unitLabel || row.unit || '吨' }}</span>
+          </div>
+          <BrandQtyChipsField
+            v-if="row.brandMode"
+            v-model="row.brandQtyRows"
+            :brand-mode="brandModeForField(row)"
+            :required-brand-id="row.brandId"
+            :brand-options="row.brandOptions"
+            :unit="row.unitLabel || row.unit || '吨'"
+            variant="admin"
+            :actual-qty="Number(row.actualQty ?? 0)"
+            @update:actual-qty="(val) => syncRowActualQty(row, val)"
+          />
+        </div>
+        <div v-if="productRows.length > 1" class="pl-driver-feedback-total">
+          <span>合计回传</span>
+          <strong>{{ totalFeedbackQty }}</strong>
+        </div>
+      </section>
+    </div>
 
     <template #footer>
       <div class="dialog-footer">
@@ -75,18 +115,43 @@
 <script setup lang="ts">
 import { Check, Close } from '@element-plus/icons-vue'
 import type { FormInstance } from 'element-plus'
+import { fetchProductDetail, type ProductDetail } from '@/api/crm/product'
 import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
-import { planApi, type PlanDriverFeedbackUpdateBody } from '@/api/pl'
+import BrandQtyChipsField, {
+  type BrandOption,
+  type BrandQtyRow
+} from '@/components/business/brand-qty-chips-field/index.vue'
+import {
+  planApi,
+  type Plan,
+  type PlanDriverFeedbackUpdateBody,
+  type PlanProduct
+} from '@/api/pl'
+import { useDictStore } from '@/store/modules/dict'
+import { pickBxData } from '@/utils/http/bx'
 import { parsePlFeedbackImages } from '@/utils/pl-display'
 
+interface DriverFeedbackProductRow {
+  planProductId: string
+  productName: string
+  unit: string
+  unitLabel: string
+  brandMode?: string
+  brandId?: string
+  brandName?: string
+  brandOptions: BrandOption[]
+  actualQty: number
+  brandQtyRows: BrandQtyRow[]
+}
+
 const props = defineProps<{
   visible: boolean
   planId: string
   isEdit?: boolean
-  feedbackQty?: number | null
   feedbackRemark?: string | null
   feedbackImages?: string | string[] | null
   feedbackTime?: string | null
+  feedbackQty?: number | null
 }>()
 
 const emit = defineEmits<{
@@ -94,54 +159,236 @@ const emit = defineEmits<{
   success: []
 }>()
 
+const dictStore = useDictStore()
+
 const innerVisible = computed({
   get: () => props.visible,
   set: value => emit('update:visible', value)
 })
 
 const formRef = ref<FormInstance>()
+const loading = ref(false)
 const submitLoading = ref(false)
+const productRows = ref<DriverFeedbackProductRow[]>([])
 
 const formData = reactive({
   feedbackTime: '' as string,
-  feedbackQty: undefined as number | undefined,
   feedbackRemark: '',
   feedbackImages: ''
 })
 
 const dialogTitle = computed(() => (props.isEdit ? '编辑司机回传信息' : '补充司机回传信息'))
 
-function resetForm() {
+const totalFeedbackQty = computed(() =>
+  Number(
+    productRows.value.reduce((sum, row) => sum + Number(row.actualQty ?? 0), 0).toFixed(3)
+  )
+)
+
+function brandModeForField(row: DriverFeedbackProductRow): 'any' | 'required' | null {
+  if (row.brandMode === 'any' || row.brandMode === 'required') return row.brandMode
+  return null
+}
+
+function syncRowActualQty(row: DriverFeedbackProductRow, val: number) {
+  row.actualQty = Number(val)
+}
+
+function onActualQtyChange(row: DriverFeedbackProductRow) {
+  if (row.brandMode === 'required' && row.brandQtyRows.length) {
+    row.brandQtyRows[0].qty = Number(row.actualQty ?? 0)
+  }
+}
+
+function brandQtySum(rows: BrandQtyRow[]) {
+  return rows.reduce((sum, row) => sum + Number(row.qty || 0), 0)
+}
+
+function resolveUnitLabel(unit?: string) {
+  const code = (unit || '').trim()
+  return dictStore.getLabel('crm_product_unit', code) || code || '吨'
+}
+
+function mapBrandQtyRows(
+  prod: PlanProduct,
+  brandOptions: BrandOption[],
+  seedActualQty: number
+): BrandQtyRow[] {
+  if (prod.brandMode === 'required') {
+    const brandId = prod.brandId || brandOptions[0]?.brandId || ''
+    const brandName =
+      prod.brandName ||
+      brandOptions.find(o => o.brandId === brandId)?.brandName ||
+      '指定品牌'
+    return brandId ? [{ brandId, brandName, qty: seedActualQty }] : []
+  }
+  if (prod.brandMode === 'any') {
+    const rows = brandOptions.map(opt => ({
+      brandId: opt.brandId,
+      brandName: opt.brandName,
+      qty: 0
+    }))
+    if (rows.length === 1) {
+      rows[0].qty = seedActualQty
+    }
+    return rows
+  }
+  return []
+}
+
+async function loadBrandOptions(productId: string): Promise<BrandOption[]> {
+  if (!productId) return []
+  try {
+    const res = await fetchProductDetail({ id: productId })
+    const detail = pickBxData<ProductDetail>(res)
+    return (detail?.brands || [])
+      .filter(b => (b.status || 'on') === 'on' && b.id && b.brandName)
+      .map(b => ({
+        brandId: String(b.id),
+        brandName: String(b.brandName)
+      }))
+  } catch {
+    return []
+  }
+}
+
+function resolveSeedActualQty(
+  prod: PlanProduct,
+  transportFeedbackQty: number | null | undefined,
+  productCount: number
+) {
+  if (prod.actualQty != null) return Number(prod.actualQty)
+  if (productCount === 1 && transportFeedbackQty != null) {
+    return Number(transportFeedbackQty)
+  }
+  return 0
+}
+
+async function buildProductRows(plan: Plan, transportFeedbackQty?: number | null) {
+  const products = Array.isArray(plan.products) ? plan.products : []
+  const brandOptionMap = new Map<string, BrandOption[]>()
+
+  await Promise.all(
+    [...new Set(products.filter(p => p.brandMode === 'any' && p.productId).map(p => p.productId))].map(
+      async productId => {
+        brandOptionMap.set(productId, await loadBrandOptions(productId))
+      }
+    )
+  )
+
+  productRows.value = products.map(prod => {
+    const brandOptions =
+      prod.brandMode === 'any'
+        ? brandOptionMap.get(prod.productId) || []
+        : prod.brandId
+          ? [{ brandId: prod.brandId, brandName: prod.brandName || '指定品牌' }]
+          : []
+    const seedActualQty = resolveSeedActualQty(prod, transportFeedbackQty, products.length)
+    const brandQtyRows = mapBrandQtyRows(prod, brandOptions, seedActualQty)
+    const actualQty = prod.brandMode
+      ? Number(brandQtySum(brandQtyRows).toFixed(3))
+      : seedActualQty
+
+    return {
+      planProductId: prod.id,
+      productName: prod.productName || '',
+      unit: prod.unit || '吨',
+      unitLabel: resolveUnitLabel(prod.unit),
+      brandMode: prod.brandMode,
+      brandId: prod.brandId,
+      brandName: prod.brandName,
+      brandOptions,
+      actualQty,
+      brandQtyRows
+    }
+  })
+}
+
+function resetMetaForm() {
   formData.feedbackTime = props.feedbackTime ? String(props.feedbackTime) : ''
-  formData.feedbackQty =
-    props.feedbackQty != null && props.feedbackQty !== undefined
-      ? Number(props.feedbackQty)
-      : undefined
   formData.feedbackRemark = props.feedbackRemark ? String(props.feedbackRemark) : ''
   const images = parsePlFeedbackImages(props.feedbackImages)
   formData.feedbackImages = images.length ? images.join(',') : ''
 }
 
-function handleOpened() {
-  resetForm()
+async function handleOpened() {
+  if (!props.planId) return
+  loading.value = true
+  productRows.value = []
+  resetMetaForm()
+  try {
+    const res = await planApi.get(props.planId)
+    const plan = pickBxData<Plan>(res)
+    if (!plan?.id) {
+      throw new Error('运输计划数据无效')
+    }
+    await buildProductRows(plan, props.feedbackQty)
+  } catch (e: unknown) {
+    innerVisible.value = false
+    const message = e instanceof Error ? e.message : '加载失败'
+    ElMessage.error(message.includes('加载') ? message : `加载运输计划失败:${message}`)
+  } finally {
+    loading.value = false
+  }
 }
 
 function handleClosed() {
   formRef.value?.resetFields()
+  productRows.value = []
+}
+
+function validateProductRows(): string | null {
+  if (!productRows.value.length) {
+    return '暂无产品明细,无法保存'
+  }
+  for (const row of productRows.value) {
+    if (row.actualQty == null || Number(row.actualQty) < 0) {
+      return `「${row.productName || '产品'}」请填写有效的实际数量`
+    }
+    if (!row.brandMode) continue
+    const positiveRows = (row.brandQtyRows || []).filter(r => Number(r.qty) > 0)
+    if (!positiveRows.length) {
+      return `「${row.productName || '产品'}」请至少填写一个品牌数量`
+    }
+    const total = Number(brandQtySum(row.brandQtyRows).toFixed(3))
+    const actual = Number(row.actualQty ?? 0)
+    if (Math.abs(total - actual) >= 0.0005) {
+      return `「${row.productName || '产品'}」品牌数量合计须等于实际数量`
+    }
+  }
+  return null
 }
 
 async function handleSubmit() {
   if (!props.planId) return
+  const validationMessage = validateProductRows()
+  if (validationMessage) {
+    ElMessage.warning(validationMessage)
+    return
+  }
+
   submitLoading.value = true
   try {
     const imageList = formData.feedbackImages
       ? formData.feedbackImages.split(',').map(item => item.trim()).filter(Boolean)
       : []
     const body: PlanDriverFeedbackUpdateBody = {
-      feedbackQty: formData.feedbackQty ?? null,
       feedbackRemark: formData.feedbackRemark.trim() || null,
       feedbackImages: imageList,
-      feedbackTime: formData.feedbackTime || null
+      feedbackTime: formData.feedbackTime || null,
+      products: productRows.value.map(row => ({
+        planProductId: row.planProductId,
+        actualQty: Number(row.actualQty ?? 0),
+        brandQtys: row.brandMode
+          ? (row.brandQtyRows || [])
+              .filter(r => Number(r.qty) > 0)
+              .map(r => ({
+                brandId: r.brandId,
+                brandName: r.brandName,
+                qty: Number(r.qty)
+              }))
+          : undefined
+      }))
     }
     await planApi.updateDriverFeedback(props.planId, body)
     ElMessage.success('司机回传信息已保存')
@@ -162,4 +409,95 @@ async function handleSubmit() {
   justify-content: flex-end;
   gap: 8px;
 }
+
+.pl-driver-feedback-form {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+
+.pl-driver-feedback-products__title {
+  margin: 0 0 10px;
+  padding-left: 10px;
+  font-size: 13px;
+  font-weight: 600;
+  color: var(--section-bar-title-color, var(--primary-dark, #059669));
+  border-left: 3px solid var(--section-bar-border-color, var(--primary, #10b981));
+  line-height: 1;
+}
+
+.pl-driver-feedback-products__empty {
+  margin: 0;
+  font-size: 13px;
+  color: var(--mint-text-muted);
+}
+
+.pl-driver-feedback-product {
+  padding: 12px;
+  border: 1px solid #e2e8f0;
+  border-radius: 8px;
+  background: #f8fafc;
+
+  & + & {
+    margin-top: 10px;
+  }
+}
+
+.pl-driver-feedback-product__head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+  margin-bottom: 10px;
+}
+
+.pl-driver-feedback-product__name {
+  font-size: 14px;
+  font-weight: 600;
+  color: #1e293b;
+}
+
+.pl-driver-feedback-product__badge {
+  font-size: 12px;
+  color: #64748b;
+}
+
+.pl-driver-feedback-product__actual {
+  display: grid;
+  grid-template-columns: 72px 1fr auto;
+  gap: 8px;
+  align-items: center;
+  margin-bottom: 4px;
+}
+
+.pl-driver-feedback-product__actual-label {
+  font-size: 13px;
+  color: #64748b;
+}
+
+.pl-driver-feedback-product__actual-input {
+  width: 100%;
+}
+
+.pl-driver-feedback-product__unit {
+  font-size: 12px;
+  font-weight: 600;
+  color: #64748b;
+}
+
+.pl-driver-feedback-total {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-top: 12px;
+  padding: 10px 12px;
+  border-radius: 8px;
+  background: #ecfdf5;
+  color: #047857;
+  font-size: 13px;
+
+  strong {
+    font-size: 16px;
+  }
+}
 </style>

+ 1 - 1
web-admin/src/components/business/pl-plan-view-tabs/index.vue

@@ -470,10 +470,10 @@
       v-model:visible="driverFeedbackDialogVisible"
       :plan-id="planId"
       :is-edit="hasDriverFeedback"
-      :feedback-qty="transport?.feedbackQty"
       :feedback-remark="transport?.feedbackRemark"
       :feedback-images="transport?.feedbackImages"
       :feedback-time="transport?.feedbackTime"
+      :feedback-qty="transport?.feedbackQty"
       @success="handleFeedbackSaved"
     />
     <PlPlanSiteFeedbackDialog