|
|
@@ -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>
|