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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-07-31 05:29:21 +08:00
1 parent 0667e8d77b
commit 2a215b2e1f
4 files changed
+436 -72

No files matched your search

+111 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+291 -69
View File
@@ -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;
.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;
}
</style>