소스 검색

feat(pl): driver public feedback by product and brand qty

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 3 달 전
부모
커밋
2a215b2e1f

+ 111 - 2
backend/app/api/pl/public_transport.py

@@ -15,7 +15,16 @@ from app.core.config import settings
 from app.core.database import get_db
 from app.core.response import error_response, success_response
 from app.crud import oss as oss_svc
-from app.models.pl_models import PlPlan, PlPlanProduct, PlPlanTransport, PlTransport
+from app.models.crm_models import CrmProductBrand
+from app.models.pl_models import (
+    PlPlan,
+    PlPlanProduct,
+    PlPlanTransport,
+    PlTransport,
+    PlTransportProductBrandQty,
+    PlTransportProductFeedback,
+)
+from app.schemas.pl.io_order import BrandOption, BrandQtyItem
 from app.schemas.pl.resource_snapshot import parse_resource_snapshot
 from app.schemas.pl.transport import (
     PublicTransportFeedback,
@@ -23,6 +32,7 @@ from app.schemas.pl.transport import (
     PublicTransportPersonnelRead,
     PublicTransportPlanProductRead,
 )
+from app.services.pl_feedback_brand_qty import persist_transport_product_feedback
 from app.utils.excel_export import load_dict_label_maps
 
 router = APIRouter(prefix="/public/pl/transport", tags=["运输计划-司机公共"])
@@ -128,19 +138,86 @@ def _build_public_page(db: Session, transport: PlTransport) -> PublicTransportPa
                 .all()
             )
 
+    crm_product_ids = {(p.product_id or "").strip() for p in products if (p.product_id or "").strip()}
+    brand_options_by_product: dict[str, list[BrandOption]] = {}
+    if crm_product_ids:
+        brand_rows = (
+            db.query(CrmProductBrand)
+            .filter(
+                CrmProductBrand.product_id.in_(list(crm_product_ids)),
+                CrmProductBrand.tenant_no == transport.tenant_no,
+                CrmProductBrand.status == "on",
+                CrmProductBrand.del_flag == 0,
+            )
+            .order_by(CrmProductBrand.order_num.asc(), CrmProductBrand.create_time.asc())
+            .all()
+        )
+        for row in brand_rows:
+            brand_options_by_product.setdefault(row.product_id, []).append(
+                BrandOption(brand_id=row.id, brand_name=row.brand_name or "")
+            )
+
+    feedback_by_plan_product: dict[str, PlTransportProductFeedback] = {}
+    brand_qtys_by_plan_product: dict[str, list[BrandQtyItem]] = {}
+    feedback_rows = (
+        db.query(PlTransportProductFeedback)
+        .filter(
+            PlTransportProductFeedback.transport_id == transport.id,
+            PlTransportProductFeedback.tenant_no == transport.tenant_no,
+            PlTransportProductFeedback.del_flag == 0,
+        )
+        .order_by(PlTransportProductFeedback.order_num.asc())
+        .all()
+    )
+    for fb in feedback_rows:
+        feedback_by_plan_product[fb.plan_product_id] = fb
+
+    if feedback_rows:
+        feedback_ids = [fb.id for fb in feedback_rows]
+        brand_qty_rows = (
+            db.query(PlTransportProductBrandQty)
+            .filter(
+                PlTransportProductBrandQty.transport_id == transport.id,
+                PlTransportProductBrandQty.tenant_no == transport.tenant_no,
+                PlTransportProductBrandQty.del_flag == 0,
+                PlTransportProductBrandQty.feedback_id.in_(feedback_ids),
+            )
+            .order_by(PlTransportProductBrandQty.order_num.asc())
+            .all()
+        )
+        for row in brand_qty_rows:
+            brand_qtys_by_plan_product.setdefault(row.plan_product_id, []).append(
+                BrandQtyItem(
+                    brand_id=row.brand_id or "",
+                    brand_name=row.brand_name,
+                    qty=row.qty or 0,
+                )
+            )
+
     product_reads = []
     unit_map = load_dict_label_maps(
         db, [CRM_PRODUCT_UNIT], tenant_no=transport.tenant_no
     ).get(CRM_PRODUCT_UNIT, {})
     for prod in products:
+        product_id = (prod.product_id or "").strip()
+        options = brand_options_by_product.get(product_id) or None
+        fb = feedback_by_plan_product.get(prod.id)
+        brand_qtys = brand_qtys_by_plan_product.get(prod.id) or None
         product_reads.append(
             PublicTransportPlanProductRead(
+                plan_product_id=prod.id,
                 product_name=prod.product_name or "",
                 model=prod.model,
                 package_spec=prod.package_spec,
                 unit=_resolve_unit_label(unit_map, prod.unit),
                 quantity=prod.quantity or prod.inbound_estimate_qty or prod.outbound_estimate_qty,
                 remark=(prod.remark or "").strip() or None,
+                brand_mode=prod.brand_mode,
+                brand_id=prod.brand_id,
+                brand_name=prod.brand_name,
+                brand_options=options,
+                actual_qty=fb.actual_qty if fb else None,
+                brand_qtys=brand_qtys,
             )
         )
 
@@ -203,7 +280,39 @@ def save_public_feedback(
     if payload.feedback_remark is not None:
         transport.feedback_remark = payload.feedback_remark
 
-    if payload.feedback_qty is not None:
+    if payload.products:
+        link = (
+            db.query(PlPlanTransport)
+            .filter(
+                PlPlanTransport.transport_id == transport.id,
+                PlPlanTransport.del_flag == 0,
+            )
+            .order_by(PlPlanTransport.create_time.desc())
+            .first()
+        )
+        if not link or not link.plan_id:
+            return error_response(400, "未找到关联计划,无法保存产品回传")
+        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=transport.tenant_no,
+                transport=transport,
+                plan_id=link.plan_id,
+                products=products_payload,
+                user_id=None,
+                enforce_brand=True,
+            )
+        except ValueError as exc:
+            return error_response(400, str(exc))
+    elif payload.feedback_qty is not None:
         transport.feedback_qty = payload.feedback_qty
 
     transport.feedback_time = datetime.now()

+ 16 - 0
backend/app/schemas/pl/transport.py

@@ -6,6 +6,7 @@ from typing import Optional
 from pydantic import BaseModel, Field
 
 from app.schemas.pl.datetime_parse import OptionalPlanDateTime
+from app.schemas.pl.io_order import BrandOption, BrandQtyItem
 from app.schemas.pl.resource_snapshot import PlanResourceSnapshotMixin
 
 
@@ -188,20 +189,35 @@ class TransportFeedback(BaseModel):
     model_config = {"populate_by_name": True}
 
 
+class PublicTransportProductFeedbackItem(BaseModel):
+    plan_product_id: str = Field(..., alias="planProductId")
+    actual_qty: Decimal = Field(..., alias="actualQty", ge=0)
+    brand_qtys: Optional[list[BrandQtyItem]] = Field(default=None, alias="brandQtys")
+    model_config = {"populate_by_name": True}
+
+
 class PublicTransportFeedback(BaseModel):
     feedback_images: Optional[list[str]] = Field(default=None, alias="feedbackImages")
     feedback_remark: Optional[str] = Field(default=None, alias="feedbackRemark")
     feedback_qty: Optional[Decimal] = Field(default=None, alias="feedbackQty", ge=0, description="回传数量")
+    products: Optional[list[PublicTransportProductFeedbackItem]] = None
     model_config = {"populate_by_name": True}
 
 
 class PublicTransportPlanProductRead(BaseModel):
+    plan_product_id: str = Field(alias="planProductId")
     product_name: str = Field(alias="productName")
     model: Optional[str] = None
     package_spec: Optional[str] = Field(default=None, alias="packageSpec")
     unit: Optional[str] = None
     quantity: Optional[Decimal] = None
     remark: Optional[str] = Field(default=None, alias="remark", description="运输计划产品备注")
+    brand_mode: Optional[str] = Field(default=None, alias="brandMode")
+    brand_id: Optional[str] = Field(default=None, alias="brandId")
+    brand_name: Optional[str] = Field(default=None, alias="brandName")
+    brand_options: Optional[list[BrandOption]] = Field(default=None, alias="brandOptions")
+    actual_qty: Optional[Decimal] = Field(default=None, alias="actualQty")
+    brand_qtys: Optional[list[BrandQtyItem]] = Field(default=None, alias="brandQtys")
     model_config = {"populate_by_name": True}
 
 

+ 18 - 1
web-admin/src/api/pl/public-transport.ts

@@ -1,12 +1,25 @@
 import { publicPlGet, publicPlPost, publicPlUpload } from '@/utils/http/public-pl'
 
+export interface BrandQtyItem {
+  brandId: string
+  brandName?: string
+  qty: number
+}
+
 export interface PublicTransportProduct {
+  planProductId: string
   productName: string
   model?: string
   packageSpec?: string
   unit?: string
   quantity?: number
   remark?: string
+  brandMode?: string | null
+  brandId?: string | null
+  brandName?: string | null
+  brandOptions?: { brandId: string; brandName: string }[]
+  actualQty?: number | null
+  brandQtys?: BrandQtyItem[]
 }
 
 export interface PublicTransportPersonnel {
@@ -51,7 +64,11 @@ export const publicTransportApi = {
   getPage: (token: string) => publicPlGet<PublicTransportPage>(`/public/pl/transport/${token}`),
   saveFeedback: (
     token: string,
-    data: { feedbackImages?: string[]; feedbackRemark?: string; feedbackQty?: number | null }
+    data: {
+      feedbackImages?: string[]
+      feedbackRemark?: string
+      products: { planProductId: string; actualQty: number; brandQtys?: BrandQtyItem[] }[]
+    }
   ) => publicPlPost<{ success: boolean }>(`/public/pl/transport/${token}/feedback`, data),
   uploadAttachment: (token: string, file: File) =>
     publicPlUpload<PublicUploadResult>(`/public/pl/transport/${token}/attachments`, file)

+ 290 - 68
web-admin/src/views/public/pl/transport/index.vue

@@ -80,32 +80,77 @@
           />
         </van-cell-group>
 
-        <van-cell-group v-if="page.products.length" inset class="section-card" title="产品明细">
-          <van-cell
-            v-for="(item, idx) in page.products"
-            :key="idx"
-            :title="formatPublicTransportProductLine(item)"
-            class="product-cell"
-          />
-        </van-cell-group>
-
-        <van-cell-group inset class="section-card" title="回传数量">
-          <div class="actual-box">
-            <div class="actual-box__label">
-              <strong>实际数量</strong>
-              <span>请按实装填写,参考现场回执</span>
+        <van-cell-group
+          v-if="editableProducts.length"
+          inset
+          class="section-card"
+          title="产品回传数量"
+        >
+          <div
+            v-for="(item, idx) in editableProducts"
+            :key="item.planProductId"
+            class="product-card"
+          >
+            <div class="product-card__head">
+              <div class="product-card__name">{{ item.productName || '产品' }}</div>
+              <span class="product-card__badge">
+                产品 {{ idx + 1 }}/{{ editableProducts.length }}
+              </span>
             </div>
-            <div class="actual-box__row">
-              <van-field
-                v-model="feedbackQtyText"
-                type="number"
-                class="actual-box__field"
-                placeholder="请输入"
-                :border="false"
+            <div class="meta-grid">
+              <div class="meta-item">
+                <div class="meta-item__label">规格型号</div>
+                <div class="meta-item__value">{{ item.model || '—' }}</div>
+              </div>
+              <div class="meta-item">
+                <div class="meta-item__label">包装规格</div>
+                <div class="meta-item__value">{{ item.packageSpec || '—' }}</div>
+              </div>
+              <div v-if="item.remark" class="meta-item meta-item--span">
+                <div class="meta-item__label">备注</div>
+                <div class="meta-item__value">{{ item.remark }}</div>
+              </div>
+              <div class="meta-item meta-item--span">
+                <div class="meta-item__label">计划数量</div>
+                <div class="meta-item__value meta-item__value--plan">
+                  {{ formatPlanQty(item) }}
+                </div>
+              </div>
+            </div>
+            <div class="actual-box">
+              <div class="actual-box__label">
+                <strong>实际数量</strong>
+                <span v-if="item.brandMode">由品牌明细合计</span>
+                <span v-else>请按实装填写,参考现场回执</span>
+              </div>
+              <div class="actual-box__row">
+                <van-field
+                  v-model="item.actualQtyText"
+                  type="number"
+                  class="actual-box__field"
+                  placeholder="请输入"
+                  :border="false"
+                  :readonly="Boolean(item.brandMode)"
+                />
+                <span class="actual-box__unit">{{ item.unit || '吨' }}</span>
+              </div>
+              <BrandQtyChipsField
+                v-if="item.brandMode"
+                v-model="item.brandQtyRows"
+                :brand-mode="brandModeForField(item)"
+                :required-brand-id="item.brandId"
+                :brand-options="item.brandOptions || []"
+                :unit="item.unit || '吨'"
+                variant="mobile"
+                :actual-qty="Number(item.actualQtyText || 0)"
+                @update:actual-qty="(val) => syncItemActualQty(item, val)"
               />
-              <span class="actual-box__unit">{{ primaryQtyUnit }}</span>
             </div>
           </div>
+          <div v-if="editableProducts.length > 1" class="total-box">
+            <span class="total-box__label">合计回传</span>
+            <span class="total-box__value">{{ totalFeedbackQty }}</span>
+          </div>
         </van-cell-group>
 
         <van-cell-group inset class="section-card" title="回执上传">
@@ -152,16 +197,24 @@
   import { showFailToast, showSuccessToast } from 'vant'
   import {
     publicTransportApi,
+    type BrandQtyItem,
     type PublicTransportPage,
-    type PublicTransportPersonnel
+    type PublicTransportPersonnel,
+    type PublicTransportProduct
   } from '@/api/pl/public-transport'
+  import BrandQtyChipsField from '@/components/business/brand-qty-chips-field/index.vue'
   import PublicMobileShell from '@/layouts/PublicMobileShell.vue'
   import { buildAmapNavigationUrl } from '@/utils/amap/navigation'
-  import { formatPublicTransportProductLine, formatPlPersonnelNameWithRole } from '@/utils/pl-display'
+  import { formatPlPersonnelNameWithRole } from '@/utils/pl-display'
 
   import 'vant/es/toast/style'
   import '@/styles/public-mobile-vant.scss'
 
+  type EditableTransportProduct = PublicTransportProduct & {
+    actualQtyText: string
+    brandQtyRows: BrandQtyItem[]
+  }
+
   const route = useRoute()
   const token = computed(() => String(route.params.token || ''))
 
@@ -170,13 +223,17 @@
   const errorMessage = ref('')
   const page = ref<PublicTransportPage | null>(null)
   const feedbackRemark = ref('')
-  const feedbackQtyText = ref('')
   const feedbackImages = ref<string[]>([])
   const uploaderFiles = ref<UploaderFileListItem[]>([])
+  const editableProducts = ref<EditableTransportProduct[]>([])
 
-  const primaryQtyUnit = computed(() => {
-    const unit = page.value?.products?.find((p) => p.unit)?.unit
-    return unit || '吨'
+  const totalFeedbackQty = computed(() => {
+    const sum = editableProducts.value.reduce(
+      (acc, item) => acc + Number(item.actualQtyText || 0),
+      0
+    )
+    const unit = editableProducts.value.find((p) => p.unit)?.unit || '吨'
+    return `${Number(sum.toFixed(3))} ${unit}`
   })
 
   function formatPersonnelLine(person: PublicTransportPersonnel) {
@@ -226,6 +283,13 @@
     return normalized.length > 16 ? normalized.slice(0, 16) : normalized
   }
 
+  function formatPlanQty(item: PublicTransportProduct) {
+    const qty = item.quantity
+    const unit = item.unit || ''
+    if (qty == null) return '—'
+    return unit ? `${qty} ${unit}` : String(qty)
+  }
+
   function resolveFileUrl(url: string) {
     if (!url) return ''
     if (/^https?:\/\//i.test(url)) return url
@@ -242,6 +306,56 @@
     })) as UploaderFileListItem[]
   }
 
+  function mapBrandQtyRows(prod: PublicTransportProduct): BrandQtyItem[] {
+    const existing = (prod.brandQtys || []).map((row) => ({
+      brandId: row.brandId,
+      brandName: row.brandName || '',
+      qty: Number(row.qty ?? 0)
+    }))
+    if (existing.length) return existing
+
+    if (prod.brandMode === 'required') {
+      const brandId = prod.brandId || prod.brandOptions?.[0]?.brandId || ''
+      const brandName =
+        prod.brandName ||
+        prod.brandOptions?.find((o) => o.brandId === brandId)?.brandName ||
+        '指定品牌'
+      const seedQty = prod.actualQty != null ? Number(prod.actualQty) : 0
+      return brandId ? [{ brandId, brandName, qty: seedQty }] : []
+    }
+
+    return []
+  }
+
+  function brandModeForField(item: EditableTransportProduct): 'any' | 'required' | null {
+    if (item.brandMode === 'any' || item.brandMode === 'required') return item.brandMode
+    return null
+  }
+
+  function syncItemActualQty(item: EditableTransportProduct, val: number) {
+    item.actualQtyText = String(val)
+  }
+
+  function brandQtySum(rows: BrandQtyItem[]) {
+    return rows.reduce((sum, row) => sum + Number(row.qty || 0), 0)
+  }
+
+  function validateBrandedProducts(): string | null {
+    for (const item of editableProducts.value) {
+      if (!item.brandMode) continue
+      const positiveRows = (item.brandQtyRows || []).filter((r) => Number(r.qty) > 0)
+      if (!positiveRows.length) {
+        return `「${item.productName || '产品'}」请至少填写一个品牌数量`
+      }
+      const total = Number(brandQtySum(item.brandQtyRows).toFixed(3))
+      const actual = Number(item.actualQtyText || 0)
+      if (Math.abs(total - actual) >= 0.0005) {
+        return `「${item.productName || '产品'}」品牌数量合计须等于实际数量`
+      }
+    }
+    return null
+  }
+
   async function loadPage() {
     loading.value = true
     errorMessage.value = ''
@@ -249,9 +363,20 @@
       const data = await publicTransportApi.getPage(token.value)
       page.value = data
       feedbackRemark.value = data.feedbackRemark || ''
-      feedbackQtyText.value =
-        data.feedbackQty != null && data.feedbackQty !== undefined ? String(data.feedbackQty) : ''
       feedbackImages.value = [...(data.feedbackImages || [])]
+      editableProducts.value = (data.products || []).map((prod) => {
+        const brandQtyRows = mapBrandQtyRows(prod)
+        return {
+          ...prod,
+          brandOptions: prod.brandOptions || [],
+          brandQtyRows,
+          actualQtyText: prod.brandMode
+            ? String(Number(brandQtySum(brandQtyRows).toFixed(3)))
+            : prod.actualQty != null
+              ? String(prod.actualQty)
+              : ''
+        }
+      })
       syncUploaderFromImages()
     } catch (e: any) {
       errorMessage.value = e?.message || '链接无效或已失效'
@@ -290,22 +415,35 @@
   }
 
   async function handleSave() {
-    const qtyRaw = feedbackQtyText.value.trim()
-    let feedbackQty: number | null = null
-    if (qtyRaw !== '') {
-      const parsed = Number(qtyRaw)
-      if (!Number.isFinite(parsed) || parsed < 0) {
-        showFailToast('请输入有效的回传数量')
-        return
-      }
-      feedbackQty = parsed
+    const brandErr = validateBrandedProducts()
+    if (brandErr) {
+      showFailToast(brandErr)
+      return
     }
+
+    const products = editableProducts.value.map((item) => {
+      const actualQty = Number(item.actualQtyText || 0)
+      return {
+        planProductId: item.planProductId,
+        actualQty,
+        brandQtys: item.brandMode
+          ? (item.brandQtyRows || [])
+              .filter((row) => Number(row.qty) > 0)
+              .map((row) => ({
+                brandId: row.brandId,
+                brandName: row.brandName,
+                qty: Number(row.qty)
+              }))
+          : undefined
+      }
+    })
+
     saving.value = true
     try {
       await publicTransportApi.saveFeedback(token.value, {
         feedbackRemark: feedbackRemark.value,
         feedbackImages: feedbackImages.value,
-        feedbackQty
+        products
       })
       showSuccessToast('保存成功')
       await loadPage()
@@ -388,68 +526,152 @@
     display: none;
   }
 
-  .product-cell :deep(.van-cell__title) {
-    color: #1e293b;
-    font-weight: 600;
-    line-height: 1.35;
-    white-space: normal;
-  }
-
-  .product-cell :deep(.van-cell__value) {
-    display: none;
-  }
-
-  .actual-box {
-    margin: 4px 12px 12px;
-    padding: 12px 14px;
+  .product-card {
+    margin: 10px 12px;
+    padding: 14px;
     border-radius: 12px;
     background: linear-gradient(180deg, #f0fdf4 0%, #fff 48%);
     border: 1px solid #d1fae5;
   }
 
-  .actual-box__label {
+  .product-card__head {
     display: flex;
-    align-items: baseline;
+    align-items: flex-start;
     justify-content: space-between;
     gap: 8px;
-    margin-bottom: 8px;
+    margin-bottom: 10px;
   }
 
-  .actual-box__label strong {
-    font-size: 14px;
+  .product-card__name {
+    font-size: 15px;
+    font-weight: 700;
     color: #0f172a;
+    line-height: 1.35;
   }
 
-  .actual-box__label span {
+  .product-card__badge {
+    flex-shrink: 0;
+    font-size: 10px;
+    font-weight: 600;
+    padding: 2px 8px;
+    border-radius: 999px;
+    background: #d1fae5;
+    color: #047857;
+  }
+
+  .meta-grid {
+    display: grid;
+    grid-template-columns: 1fr 1fr;
+    gap: 8px;
+    margin-bottom: 12px;
+  }
+
+  .meta-item {
+    background: rgb(255 255 255 / 85%);
+    border: 1px solid #e2e8f0;
+    border-radius: 8px;
+    padding: 8px 10px;
+  }
+
+  .meta-item--span {
+    grid-column: 1 / -1;
+  }
+
+  .meta-item__label {
+    font-size: 11px;
+    color: #64748b;
+    margin-bottom: 2px;
+  }
+
+  .meta-item__value {
+    font-size: 13px;
+    font-weight: 600;
+    color: #1e293b;
+    word-break: break-all;
+  }
+
+  .meta-item__value--plan {
+    color: #059669;
+    font-size: 15px;
+  }
+
+  .actual-box {
+    background: #fff;
+    border: 1px solid #a7f3d0;
+    border-radius: 10px;
+    padding: 10px 12px;
+  }
+
+  .actual-box__label {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    margin-bottom: 8px;
     font-size: 12px;
     color: #64748b;
+
+    strong {
+      color: #1e293b;
+      font-size: 13px;
+    }
   }
 
   .actual-box__row {
     display: flex;
     align-items: center;
     gap: 8px;
-    padding: 4px 10px;
-    border-radius: 10px;
-    background: #fff;
-    border: 1px solid #a7f3d0;
   }
 
   .actual-box__field {
     flex: 1;
     padding: 0;
+    background: #ecfdf5;
+    border: 1.5px solid #6ee7b7;
+    border-radius: 8px;
+    overflow: hidden;
+
+    :deep(.van-field__control) {
+      height: 40px;
+      padding: 0 12px;
+      font-size: 18px;
+      font-weight: 700;
+      color: #059669;
+      line-height: 40px;
+    }
   }
 
-  .actual-box__field :deep(.van-field__control) {
-    font-size: 20px;
+  .actual-box__unit {
+    flex-shrink: 0;
+    font-size: 14px;
     font-weight: 700;
     color: #059669;
+    min-width: 28px;
   }
 
-  .actual-box__unit {
-    flex-shrink: 0;
+  .total-box {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    margin: 0 12px 12px;
+    padding: 10px 14px;
+    border-radius: 10px;
+    background: #f8fafc;
+    border: 1px solid #e2e8f0;
+  }
+
+  .total-box__label {
     font-size: 13px;
     font-weight: 600;
     color: #64748b;
   }
+
+  .total-box__value {
+    font-size: 16px;
+    font-weight: 700;
+    color: #059669;
+  }
+
+  .page-footer {
+    padding: 16px;
+  }
 </style>