Files
shanxia2.0/web-admin/src/views/public/pl/_shared/PublicIoOrderPage.vue
T
zlwuandCursor 5a9abfdd5d fix(pl): keep multi-brand site feedback when plan product row is stale
Persist submitted brand qtys even if plan brand_mode lookup fails; resolve soft-deleted plan products for brand metadata instead of substituting another product; stop seeding all actual qty onto the first brand option.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 07:06:41 +08:00

662 lines
20 KiB
Vue

<template>
<PublicMobileShell>
<div class="public-pl-site-page public-pl-transport-page">
<van-nav-bar :title="navTitle" :border="false" class="site-nav-bar" />
<van-loading v-if="loading" vertical class="page-loading">加载中...</van-loading>
<van-empty v-else-if="errorMessage" :description="errorMessage" />
<template v-else-if="page">
<van-notice-bar
v-if="isReadOnly"
left-icon="info-o"
color="#92400e"
background="#fffbeb"
text="单据已作废,仅可查看"
/>
<van-cell-group inset class="section-card">
<van-cell title="业务类型" :value="page.businessType || '—'" />
<van-cell title="日期" :value="page.planDate || '—'" />
<van-cell title="往来单位" :value="page.partnerCompany || '—'" />
<van-cell title="车牌" :value="page.vehicleNo || '—'" />
<van-cell title="计划号" :value="page.planNo || '—'" />
<van-cell title="单号" :value="page.orderNo || '—'" />
<van-cell title="状态" :value="statusLabel(page.status)" />
<van-cell
v-if="page.planRemark"
title="备注"
:value="page.planRemark"
class="multiline-cell"
/>
</van-cell-group>
<van-cell-group inset class="section-card" title="取货信息">
<van-cell title="取货地址" class="address-cell multiline-cell">
<template #value>
<div class="address-cell__content">
<span class="address-cell__text">{{ page.pickupAddress || '—' }}</span>
<van-button
v-if="pickupNavUrl"
size="mini"
type="primary"
round
class="address-nav-btn"
@click="openNavigation(pickupNavUrl)"
>
导航
</van-button>
</div>
</template>
</van-cell>
<van-cell title="取货时间" :value="formatDateTime(page.pickupTime)" />
<van-cell title="注意事项" :value="page.pickupNotes || '—'" class="multiline-cell" />
</van-cell-group>
<van-cell-group inset class="section-card" title="送货信息">
<van-cell title="送货地址" class="address-cell multiline-cell">
<template #value>
<div class="address-cell__content">
<span class="address-cell__text">{{ deliveryAddressDisplay }}</span>
<van-button
v-if="destinationNavUrl"
size="mini"
type="primary"
round
class="address-nav-btn"
@click="openNavigation(destinationNavUrl)"
>
导航
</van-button>
</div>
</template>
</van-cell>
<van-cell title="送达时间" :value="formatDateTime(page.deliveryTime)" />
<van-cell title="注意事项" :value="page.destinationNotes || '—'" class="multiline-cell" />
</van-cell-group>
<van-cell-group inset class="section-card" title="产品实际数量">
<div v-for="(item, idx) in editableItems" :key="item.id" class="product-card">
<div class="product-card__head">
<div class="product-card__name">{{ item.productName || '产品' }}</div>
<span class="product-card__badge">产品 {{ idx + 1 }}/{{ editableItems.length }}</span>
</div>
<div class="meta-grid">
<div class="meta-item">
<div class="meta-item__label">别名</div>
<div class="meta-item__value">{{ item.aliasName || '—' }}</div>
</div>
<div class="meta-item">
<div class="meta-item__label">品牌</div>
<div class="meta-item__value">{{ formatBrandLabel(item) }}</div>
</div>
<div class="meta-item">
<div class="meta-item__label">规格型号</div>
<div class="meta-item__value">{{ item.specification || '—' }}</div>
</div>
<div class="meta-item">
<div class="meta-item__label">包装规格</div>
<div class="meta-item__value">{{ item.packageSpec || '—' }}</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="isReadOnly || 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 || []"
:disabled="isReadOnly"
:unit="item.unit || '吨'"
variant="mobile"
:actual-qty="Number(item.actualQtyText || 0)"
@update:actual-qty="(val) => syncItemActualQty(item, val)"
/>
</div>
</div>
</van-cell-group>
<van-cell-group inset class="section-card" title="回执上传">
<div class="uploader-wrap">
<van-uploader
v-model="uploaderFiles"
multiple
:max-count="9"
:deletable="!isReadOnly"
:show-upload="!isReadOnly"
:after-read="handleAfterRead"
:before-delete="handleBeforeDelete"
/>
</div>
</van-cell-group>
<van-cell-group v-if="!isReadOnly" inset class="section-card" title="单据状态">
<div class="status-radio-wrap">
<van-radio-group v-model="selectedStatus" direction="horizontal">
<van-radio name="in_progress">进行中</van-radio>
<van-radio name="completed">已完成</van-radio>
<van-radio name="voided">已作废</van-radio>
</van-radio-group>
</div>
</van-cell-group>
<van-cell-group inset class="section-card" title="现场备注">
<van-field
v-model="feedbackRemark"
rows="4"
autosize
type="textarea"
maxlength="500"
show-word-limit
placeholder="填写现场备注"
:readonly="isReadOnly"
/>
</van-cell-group>
<van-cell-group v-if="page.siteFeedbackTime" inset class="section-card">
<van-cell title="上次保存" :value="formatDateTime(page.siteFeedbackTime)" />
</van-cell-group>
<footer v-if="!isReadOnly" class="page-footer">
<van-button type="primary" block round :loading="saving" @click="handleSave">
提交
</van-button>
</footer>
</template>
</div>
</PublicMobileShell>
</template>
<script setup lang="ts">
import type { UploaderFileListItem } from 'vant'
import { showConfirmDialog, showFailToast, showSuccessToast } from 'vant'
import {
createPublicIoOrderApi,
type BrandQtyItem,
type PublicIoOrderItem,
type PublicIoOrderPage
} from '@/api/pl/public-io-order'
import BrandQtyChipsField from '@/components/business/brand-qty-chips-field/index.vue'
import PublicMobileShell from '@/layouts/PublicMobileShell.vue'
import { buildAmapNavigationUrl } from '@/utils/amap/navigation'
import { resolvePlanBrandDisplayLabel } from '@/utils/pl-display'
import 'vant/es/dialog/style'
import 'vant/es/toast/style'
import '@/styles/public-mobile-vant.scss'
type IoOrderStatus = 'in_progress' | 'completed' | 'voided'
type EditableIoOrderItem = PublicIoOrderItem & {
actualQtyText: string
brandQtyRows: BrandQtyItem[]
}
const props = defineProps<{ kind: 'inbound' | 'outbound' }>()
const api = createPublicIoOrderApi(props.kind)
const route = useRoute()
const token = computed(() => String(route.params.token || ''))
const navTitle = computed(() =>
props.kind === 'inbound' ? '入库单 · 现场回执' : '出库单 · 现场回执'
)
const loading = ref(true)
const saving = ref(false)
const errorMessage = ref('')
const page = ref<PublicIoOrderPage | null>(null)
const feedbackRemark = ref('')
const feedbackImages = ref<string[]>([])
const uploaderFiles = ref<UploaderFileListItem[]>([])
const editableItems = ref<EditableIoOrderItem[]>([])
const selectedStatus = ref<IoOrderStatus>('in_progress')
const isReadOnly = computed(() => Boolean(page.value?.voided))
const deliveryAddressDisplay = computed(() => {
if (!page.value) return '—'
return page.value.destinationAddress || page.value.destinationName || '—'
})
const pickupNavUrl = computed(() => {
if (!page.value) return null
return buildAmapNavigationUrl(
page.value.pickupLng,
page.value.pickupLat,
page.value.pickupAddress || '取货点'
)
})
const destinationNavUrl = computed(() => {
if (!page.value) return null
return buildAmapNavigationUrl(
page.value.destinationLng,
page.value.destinationLat,
page.value.destinationAddress || page.value.destinationName || '送货点'
)
})
function openNavigation(url: string) {
window.location.href = url
}
function formatDateTime(value?: string) {
if (!value) return '—'
const normalized = String(value).replace('T', ' ')
return normalized.length > 16 ? normalized.slice(0, 16) : normalized
}
function formatPlanQty(item: PublicIoOrderItem) {
const qty = item.planQty
const unit = item.unit || ''
if (qty == null) return '—'
return unit ? `${qty} ${unit}` : String(qty)
}
function formatBrandLabel(item: PublicIoOrderItem) {
return resolvePlanBrandDisplayLabel(item) || '—'
}
function statusLabel(s?: string) {
const map: Record<string, string> = {
in_progress: '进行中',
completed: '已完成',
voided: '已作废'
}
return (s && map[s]) || s || '—'
}
function resolveFileUrl(url: string) {
if (!url) return ''
if (/^https?:\/\//i.test(url)) return url
const base = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
return base ? `${base}/${url.replace(/^\//, '')}` : url
}
function syncUploaderFromImages() {
uploaderFiles.value = feedbackImages.value.map((url, index) => ({
url: resolveFileUrl(url),
isImage: /\.(png|jpe?g|gif|webp|bmp)$/i.test(url),
name: `附件${index + 1}`,
fileUrl: url
})) as UploaderFileListItem[]
}
function mapBrandQtyRows(it: PublicIoOrderItem): BrandQtyItem[] {
const existing = (it.brandQtys || [])
.filter((row) => Number(row.qty ?? 0) > 0)
.map((row) => ({
brandId: row.brandId,
brandName: row.brandName || '',
qty: Number(row.qty ?? 0)
}))
if (existing.length) return existing
// 指定品牌:仅预选计划上的 brandId,禁止把总量塞到 options 第一项
if (it.brandMode === 'required' && it.brandId) {
const brandName =
it.brandName ||
it.brandOptions?.find((o) => o.brandId === it.brandId)?.brandName ||
'指定品牌'
const seedQty = it.actualQty != null ? Number(it.actualQty) : 0
return [{ brandId: it.brandId, brandName, qty: seedQty }]
}
return []
}
function brandModeForField(item: EditableIoOrderItem): 'any' | 'required' | null {
if (item.brandMode === 'any' || item.brandMode === 'required') return item.brandMode
return null
}
function syncItemActualQty(item: EditableIoOrderItem, val: number) {
item.actualQtyText = String(val)
}
function brandQtySum(rows: BrandQtyItem[]) {
return rows.reduce((sum, row) => sum + Number(row.qty || 0), 0)
}
function validateBrandedItemsForCompleted(): string | null {
for (const item of editableItems.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 = ''
try {
const data = await api.getPage(token.value)
page.value = data
feedbackRemark.value = data.siteFeedbackRemark || ''
feedbackImages.value = [...(data.siteFeedbackImages || [])]
editableItems.value = (data.items || []).map((it) => {
const brandQtyRows = mapBrandQtyRows(it)
return {
...it,
brandOptions: it.brandOptions || [],
brandQtyRows,
actualQtyText: it.brandMode
? String(Number(brandQtySum(brandQtyRows).toFixed(3)))
: it.actualQty != null
? String(it.actualQty)
: ''
}
})
selectedStatus.value = (
['in_progress', 'completed', 'voided'].includes(data.status || '')
? data.status
: 'in_progress'
) as IoOrderStatus
syncUploaderFromImages()
} catch (e: any) {
errorMessage.value = e?.message || '链接无效或已失效'
page.value = null
} finally {
loading.value = false
}
}
async function handleAfterRead(item: UploaderFileListItem | UploaderFileListItem[]) {
if (isReadOnly.value) return
const files = Array.isArray(item) ? item : [item]
for (const fileItem of files) {
if (!fileItem.file) continue
fileItem.status = 'uploading'
fileItem.message = '上传中...'
try {
const res = await api.uploadAttachment(token.value, fileItem.file as File)
if (res.fileUrl) {
feedbackImages.value.push(res.fileUrl)
;(fileItem as UploaderFileListItem & { fileUrl?: string }).fileUrl = res.fileUrl
fileItem.url = resolveFileUrl(res.fileUrl)
fileItem.status = 'done'
fileItem.message = ''
}
} catch (e: any) {
fileItem.status = 'failed'
fileItem.message = e?.message || '上传失败'
}
}
}
function handleBeforeDelete(item: UploaderFileListItem) {
if (isReadOnly.value) return false
const fileUrl = (item as UploaderFileListItem & { fileUrl?: string }).fileUrl
if (fileUrl) {
feedbackImages.value = feedbackImages.value.filter((u) => u !== fileUrl)
}
return true
}
async function handleSave() {
if (isReadOnly.value) return
if (selectedStatus.value === 'voided') {
try {
await showConfirmDialog({
title: '确认作废',
message: '确认作废本出入库单?作废后不可再提交。'
})
} catch {
return
}
}
if (selectedStatus.value === 'completed') {
const brandErr = validateBrandedItemsForCompleted()
if (brandErr) {
showFailToast(brandErr)
return
}
}
saving.value = true
try {
await api.saveFeedback(token.value, {
status: selectedStatus.value,
remark: feedbackRemark.value,
images: feedbackImages.value,
items: editableItems.value.map((it) => ({
id: it.id,
actualQty: Number(it.actualQtyText || 0),
brandQtys: it.brandMode
? (it.brandQtyRows || [])
.filter((row) => Number(row.qty) > 0)
.map((row) => ({
brandId: row.brandId,
brandName: row.brandName,
qty: Number(row.qty)
}))
: undefined
}))
})
showSuccessToast('提交成功')
await loadPage()
} catch (e: any) {
showFailToast(e?.message || '提交失败')
} finally {
saving.value = false
}
}
watch(token, loadPage, { immediate: true })
</script>
<style scoped lang="scss">
.page-loading {
display: flex;
justify-content: center;
padding: 48px 0;
}
.site-nav-bar {
margin: -12px -16px 12px;
border-radius: 0 0 12px 12px;
overflow: hidden;
:deep(.van-nav-bar) {
background: #059669;
}
:deep(.van-nav-bar__title) {
color: #fff;
font-weight: 600;
}
}
.section-card {
margin-bottom: 10px;
}
.uploader-wrap {
padding: 8px 14px 12px;
}
.status-radio-wrap {
padding: 12px 16px 16px;
}
.page-footer {
padding: 16px;
}
.multiline-cell :deep(.van-cell__value) {
text-align: left;
line-height: 1.35;
white-space: normal;
}
.address-cell__content {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 8px;
justify-content: flex-start;
}
.address-cell__text {
line-height: 1.35;
color: #1e293b;
font-weight: 600;
}
.address-nav-btn {
flex-shrink: 0;
height: 22px;
padding: 0 8px;
font-size: 11px;
line-height: 22px;
}
.product-card {
margin: 10px 12px;
padding: 14px;
border-radius: 12px;
background: linear-gradient(180deg, #f0fdf4 0%, #fff 48%);
border: 1px solid #d1fae5;
}
.product-card__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
margin-bottom: 10px;
}
.product-card__name {
font-size: 15px;
font-weight: 700;
color: #0f172a;
line-height: 1.35;
}
.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;
}
.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__unit {
flex-shrink: 0;
font-size: 14px;
font-weight: 700;
color: #059669;
min-width: 28px;
}
</style>