From 2a215b2e1f53422dfb169d48bc4de2246209b7fa Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Fri, 31 Jul 2026 05:29:21 +0800 Subject: [PATCH] feat(pl): driver public feedback by product and brand qty Co-authored-by: Cursor --- backend/app/api/pl/public_transport.py | 113 +++++- backend/app/schemas/pl/transport.py | 16 + web-admin/src/api/pl/public-transport.ts | 19 +- .../src/views/public/pl/transport/index.vue | 360 ++++++++++++++---- 4 files changed, 436 insertions(+), 72 deletions(-) diff --git a/backend/app/api/pl/public_transport.py b/backend/app/api/pl/public_transport.py index fb7c10e83..8fadbcc29 100644 --- a/backend/app/api/pl/public_transport.py +++ b/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() diff --git a/backend/app/schemas/pl/transport.py b/backend/app/schemas/pl/transport.py index 8e500ef26..0e80c4f39 100644 --- a/backend/app/schemas/pl/transport.py +++ b/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} diff --git a/web-admin/src/api/pl/public-transport.ts b/web-admin/src/api/pl/public-transport.ts index e9ed488df..68e0d4d8d 100644 --- a/web-admin/src/api/pl/public-transport.ts +++ b/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(`/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(`/public/pl/transport/${token}/attachments`, file) diff --git a/web-admin/src/views/public/pl/transport/index.vue b/web-admin/src/views/public/pl/transport/index.vue index 263a7fc1b..4b97d34ca 100644 --- a/web-admin/src/views/public/pl/transport/index.vue +++ b/web-admin/src/views/public/pl/transport/index.vue @@ -80,32 +80,77 @@ /> - - - - - -
-
- 实际数量 - 请按实装填写,参考现场回执 + +
+
+
{{ item.productName || '产品' }}
+ + 产品 {{ idx + 1 }}/{{ editableProducts.length }} +
-
- +
+
规格型号
+
{{ item.model || '—' }}
+
+
+
包装规格
+
{{ item.packageSpec || '—' }}
+
+
+
备注
+
{{ item.remark }}
+
+
+
计划数量
+
+ {{ formatPlanQty(item) }} +
+
+
+
+
+ 实际数量 + 由品牌明细合计 + 请按实装填写,参考现场回执 +
+
+ + {{ item.unit || '吨' }} +
+ - {{ primaryQtyUnit }}
+
+ 合计回传 + {{ totalFeedbackQty }} +
@@ -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(null) const feedbackRemark = ref('') - const feedbackQtyText = ref('') const feedbackImages = ref([]) const uploaderFiles = ref([]) + const editableProducts = ref([]) - 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; - .actual-box__field :deep(.van-field__control) { - font-size: 20px; - font-weight: 700; - color: #059669; + :deep(.van-field__control) { + height: 40px; + padding: 0 12px; + font-size: 18px; + font-weight: 700; + color: #059669; + line-height: 40px; + } } .actual-box__unit { flex-shrink: 0; + font-size: 14px; + font-weight: 700; + color: #059669; + min-width: 28px; + } + + .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; + }