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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-07-31 05:31:22 +08:00
1 parent 2a215b2e1f
commit b7236329c2
5 files changed
+458 -69

No files matched your search

+22 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
@@ -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>
@@ -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