This commit is contained in:
zlwu committed 2026-08-23 18:42:27 +08:00
1 parent b74d483682
commit dd1acdc787
50 files changed
+636156 -7218

No files matched your search

@@ -29,7 +29,9 @@
<PlPlanFormProductTable
:products="productRows"
show-actual-qty
show-archive-io-fields
editable-actual-qty-only
editable-archive-io-fields
/>
<div
v-for="row in brandedProductRows"
@@ -122,6 +124,7 @@
embedded
hide-nav
:tab="viewTab"
show-archive-io-fields
feedback-editable
:arrange-driver-url="driverUrl"
:arrange-site-records="siteRecords"
@@ -263,6 +266,7 @@
function isBrandQtySumMatch(row: PlanFormProductRow): boolean {
if (!row.brandMode) return true
if (row.actualQty == null) return true
return Math.abs(brandQtySum(row) - Number(row.actualQty ?? 0)) < 0.0005
}
@@ -288,6 +292,20 @@
unit: item.unit || '吨',
quantity: Number(item.quantity ?? item.inboundEstimateQty ?? item.outboundEstimateQty ?? 0),
actualQty: item.actualQty != null ? Number(item.actualQty) : undefined,
inboundQty: item.inboundQty != null ? Number(item.inboundQty) : undefined,
inboundPrice:
item.inboundPrice != null
? Number(item.inboundPrice)
: item.taxPrice != null
? Number(item.taxPrice)
: undefined,
outboundQty: item.outboundQty != null ? Number(item.outboundQty) : undefined,
outboundPrice:
item.outboundPrice != null
? Number(item.outboundPrice)
: item.taxPrice != null
? Number(item.taxPrice)
: undefined,
packageSpec: item.packageSpec,
deliveryMethod: item.deliveryMethod,
taxRate: item.taxRate != null ? Number(item.taxRate) : undefined,
@@ -506,8 +524,28 @@
ElMessage.error('产品明细缺少行 ID,无法提交')
return
}
if (productRows.value.some((row) => row.actualQty == null || Number(row.actualQty) < 0)) {
ElMessage.warning('请填写有效的实际数量')
if (productRows.value.some((row) => row.actualQty != null && Number(row.actualQty) < 0)) {
ElMessage.warning('实际数量不能为负数')
return
}
if (
productRows.value.some(
(row) =>
row.inboundQty != null && Number(row.inboundQty) < 0 ||
row.outboundQty != null && Number(row.outboundQty) < 0
)
) {
ElMessage.warning('入库/出库数量不能为负数')
return
}
if (
productRows.value.some(
(row) =>
row.inboundPrice != null && Number(row.inboundPrice) < 0 ||
row.outboundPrice != null && Number(row.outboundPrice) < 0
)
) {
ElMessage.warning('入库/出库价不能为负数')
return
}
const brandMismatch = brandedProductRows.value.find((row) => !isBrandQtySumMatch(row))
@@ -543,7 +581,11 @@
const body: PlanArchiveBody = {
products: productRows.value.map((row) => ({
id: row.id!,
actualQty: Number(row.actualQty ?? 0),
...(row.actualQty != null ? { actualQty: Number(row.actualQty) } : {}),
...(row.inboundQty != null ? { inboundQty: Number(row.inboundQty) } : {}),
...(row.inboundPrice != null ? { inboundPrice: Number(row.inboundPrice) } : {}),
...(row.outboundQty != null ? { outboundQty: Number(row.outboundQty) } : {}),
...(row.outboundPrice != null ? { outboundPrice: Number(row.outboundPrice) } : {}),
brandQtys: row.brandMode
? (row.brandQtyRows || [])
.filter((r) => Number(r.qty) > 0)
@@ -307,24 +307,9 @@
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
command="inbound"
:disabled="!canPrintSlipCommand(plan.planType, 'inbound')"
>
入库单
</el-dropdown-item>
<el-dropdown-item
command="delivery"
:disabled="!canPrintSlipCommand(plan.planType, 'delivery')"
>
送(提)货单
</el-dropdown-item>
<el-dropdown-item
command="subpack"
:disabled="!canPrintSlipCommand(plan.planType, 'subpack')"
>
分装单
</el-dropdown-item>
<el-dropdown-item command="inbound">入库单</el-dropdown-item>
<el-dropdown-item command="delivery">送(提)货单</el-dropdown-item>
<el-dropdown-item command="subpack">分装单</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
@@ -375,7 +360,7 @@ import { useDictStore } from '@/store/modules/dict'
import { isTonProductUnit } from '@/utils/crm-product-display'
import { formatDate } from '@/utils/format'
import { pickBxData } from '@/utils/http/bx'
import { canPrintSlipCommand, formatPlVehiclePlateDisplay, type PlPrintSlipCommand } from '@/utils/pl-display'
import { formatPlVehiclePlateDisplay, type PlPrintSlipCommand } from '@/utils/pl-display'
import { getPersonnelAffiliationLabel } from '@/utils/pl-personnel-cert'
const dictStore = useDictStore()
@@ -879,10 +864,6 @@ function handlePrintSlip(command: string) {
return
}
const slipCommand = command as PlPrintSlipCommand
if (!canPrintSlipCommand(plan.value.planType, slipCommand)) {
ElMessage.warning('当前单据类型不支持打印该单据')
return
}
if (slipCommand === 'inbound') {
inboundSlipPrintRef.value?.open(planId)
return
@@ -0,0 +1,220 @@
<template>
<div v-if="items.length" class="pl-plan-document-attachment-cell">
<div
v-for="item in items"
:key="item.key"
class="pl-plan-document-attachment-cell__item"
:title="item.fullLabel"
>
<div class="pl-plan-document-attachment-cell__thumb">
<div
class="pl-plan-document-attachment-cell__preview"
:title="item.isImage || item.isPdf ? '点击在新标签页查看' : '点击查看'"
@click.stop="handlePreview(item)"
>
<div class="pl-plan-document-attachment-cell__placeholder is-clickable">
<el-icon :size="16" color="#10b981"><Picture /></el-icon>
</div>
</div>
<el-icon
class="pl-plan-document-attachment-cell__download"
title="下载"
@click.stop="handleDownload(item)"
>
<Download />
</el-icon>
<span class="pl-plan-document-attachment-cell__caption">{{ item.label }}</span>
</div>
</div>
</div>
<span v-else class="pl-plan-document-attachment-cell__none">—</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Download, Picture } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import type { PlanDocumentAttachmentItem } from '@/api/pl'
import {
downloadAttachmentFile,
openAttachmentPreviewInNewTab
} from '@/utils/download-attachment'
import { resolvePhotoDisplayUrl } from '@/utils/pl-photo'
const props = withDefaults(
defineProps<{
attachments?: PlanDocumentAttachmentItem[] | null
mediaActive?: boolean
}>(),
{
attachments: () => [],
mediaActive: true
}
)
type ItemDef = {
key: string
label: string
fullLabel: string
url: string
isImage: boolean
isPdf: boolean
}
function isImageUrl(url: string) {
const path = String(url || '')
.split('?')[0]
.toLowerCase()
return /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(path)
}
function isPdfUrl(url: string) {
const path = String(url || '')
.split('?')[0]
.toLowerCase()
return /\.pdf$/i.test(path)
}
function fileNameFromUrl(url: string) {
return (
String(url || '')
.split('?')[0]
.split('/')
.pop() || '附件'
)
}
async function handlePreview(item: ItemDef) {
if (!item.url) return
await openAttachmentPreviewInNewTab(
{ fileUrl: item.url, fileName: fileNameFromUrl(item.url) },
{
fullFileUrl: resolvePhotoDisplayUrl,
kind: item.isImage ? 'image' : item.isPdf ? 'pdf' : 'other'
}
)
}
async function handleDownload(item: ItemDef) {
if (!item.url) {
ElMessage.error('无法获取文件下载地址')
return
}
try {
await downloadAttachmentFile(
{ fileUrl: item.url, fileName: fileNameFromUrl(item.url) },
{ fullFileUrl: resolvePhotoDisplayUrl }
)
ElMessage.success('下载成功')
} catch {
ElMessage.error('下载失败,请稍后重试')
}
}
const items = computed<ItemDef[]>(() => {
const list = props.attachments || []
return list
.filter((item) => String(item?.url || '').trim())
.map((item, index) => {
const url = String(item.url).trim()
const fullLabel = String(item.label || '附件').trim() || '附件'
const label = fullLabel.length > 6 ? `${fullLabel.slice(0, 5)}…` : fullLabel
return {
key: `${url}-${index}`,
label,
fullLabel,
url,
isImage: isImageUrl(url),
isPdf: isPdfUrl(url)
}
})
})
</script>
<style scoped lang="scss">
.pl-plan-document-attachment-cell {
display: flex;
align-items: flex-start;
gap: 8px;
flex-wrap: wrap;
}
.pl-plan-document-attachment-cell__item {
display: flex;
flex-direction: column;
align-items: center;
width: 56px;
}
.pl-plan-document-attachment-cell__thumb {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 40px;
overflow: hidden;
border: 1px solid var(--border, #d9e8e2);
border-radius: 6px;
background: var(--table-head, #f8fafc);
}
.pl-plan-document-attachment-cell__preview {
width: 100%;
height: 100%;
cursor: pointer;
}
.pl-plan-document-attachment-cell__placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
margin-bottom: 10px;
background: var(--primary-bg, #ecfdf5);
&.is-clickable {
cursor: pointer;
}
}
.pl-plan-document-attachment-cell__download {
position: absolute;
right: 1px;
top: 1px;
z-index: 2;
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
border: 1px solid #e2e8f0;
color: #1e293b;
cursor: pointer;
font-size: 10px;
&:hover {
color: #1ca851;
border-color: #1ca851;
background: #e6f8ed;
}
}
.pl-plan-document-attachment-cell__caption {
position: absolute;
right: 0;
bottom: 0;
left: 0;
padding: 1px 2px;
background: rgba(15, 23, 42, 0.62);
color: #fff;
font-size: 10px;
line-height: 1.2;
text-align: center;
word-break: keep-all;
}
.pl-plan-document-attachment-cell__none {
color: var(--mint-text);
}
</style>
@@ -125,6 +125,98 @@
<span v-else>{{ row.actualQty ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column
v-if="showInboundOutboundQty || showArchiveIoFields"
label="入库数量"
min-width="110"
align="right"
>
<template #default="{ row }">
<el-input-number
v-if="editableArchiveIoFields"
v-model="row.inboundQty"
:min="0"
:precision="3"
:controls="false"
style="width: 100%"
/>
<span v-else>{{ row.inboundQty ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column
v-if="showArchiveIoFields"
label="入库单价(元)"
min-width="120"
align="right"
>
<template #default="{ row }">
<el-input-number
v-if="editableArchiveIoFields"
v-model="row.inboundPrice"
:min="0"
:precision="2"
:controls="false"
style="width: 100%"
/>
<span v-else>{{ row.inboundPrice ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column
v-if="showArchiveIoFields"
label="入库金额"
min-width="110"
align="right"
>
<template #default="{ row }">
{{ formatPlanProductIoAmount(row.inboundQty, row.inboundPrice) }}
</template>
</el-table-column>
<el-table-column
v-if="showInboundOutboundQty || showArchiveIoFields"
label="出库数量"
min-width="110"
align="right"
>
<template #default="{ row }">
<el-input-number
v-if="editableArchiveIoFields"
v-model="row.outboundQty"
:min="0"
:precision="3"
:controls="false"
style="width: 100%"
/>
<span v-else>{{ row.outboundQty ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column
v-if="showArchiveIoFields"
label="出库单价(元)"
min-width="120"
align="right"
>
<template #default="{ row }">
<el-input-number
v-if="editableArchiveIoFields"
v-model="row.outboundPrice"
:min="0"
:precision="2"
:controls="false"
style="width: 100%"
/>
<span v-else>{{ row.outboundPrice ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column
v-if="showArchiveIoFields"
label="出库金额"
min-width="110"
align="right"
>
<template #default="{ row }">
{{ formatPlanProductIoAmount(row.outboundQty, row.outboundPrice) }}
</template>
</el-table-column>
<el-table-column
v-if="showBrandQty"
label="品牌数量"
@@ -250,6 +342,12 @@ const props = defineProps<{
showBrandQty?: boolean
/** 仅实际数量可编辑,计划数量只读(归档弹窗) */
editableActualQtyOnly?: boolean
/** 展示入库/出库数量列(不含价格) */
showInboundOutboundQty?: boolean
/** 展示入库/出库数量、单价与计算金额列 */
showArchiveIoFields?: boolean
/** 入库/出库数量与价格可编辑(归档弹窗) */
editableArchiveIoFields?: boolean
}>()
const emit = defineEmits<{
@@ -274,6 +372,17 @@ function isProductSelected(row: PlanFormProductRow) {
return selectedProductIdSet.value.has(String(row.productId))
}
/** 入库/出库金额:数量 × 单价,保留两位小数 */
function formatPlanProductIoAmount(
qty?: number | string | null,
price?: number | string | null
) {
if (qty == null || price == null || qty === '' || price === '') return '—'
const amount = Number(qty) * Number(price)
if (!Number.isFinite(amount)) return '—'
return amount.toFixed(2)
}
function handleAliasChange(row: PlanFormProductRow) {
row.productName = resolveLineProductName(row.standardProductName, row.aliasName)
}
@@ -9,7 +9,11 @@
class="pl-plan-list-product-cell__line"
:class="{
'pl-plan-list-product-cell__line--qty':
field === 'qty' || field === 'planQty' || field === 'actualQty',
field === 'qty' ||
field === 'planQty' ||
field === 'actualQty' ||
field === 'inboundQty' ||
field === 'outboundQty',
'pl-plan-list-product-cell__line--center':
field === 'spec' || field === 'model' || field === 'packageSpec' || field === 'unit'
}"
@@ -91,7 +95,9 @@ import { useDictStore } from '@/store/modules/dict'
import {
formatPlanProductListText,
getPlanProductActualQuantity,
getPlanProductInboundQuantity,
getPlanProductModel,
getPlanProductOutboundQuantity,
getPlanProductPackageSpec,
getPlanProductPlannedQuantity,
getPlanProductQuantity,
@@ -106,6 +112,8 @@ export type PlPlanListProductCellField =
| 'qty'
| 'planQty'
| 'actualQty'
| 'inboundQty'
| 'outboundQty'
| 'unit'
const props = withDefaults(
@@ -151,6 +159,12 @@ function formatFieldValue(product: PlanProduct) {
if (props.field === 'actualQty') {
return getPlanProductActualQuantity(product)
}
if (props.field === 'inboundQty') {
return getPlanProductInboundQuantity(product)
}
if (props.field === 'outboundQty') {
return getPlanProductOutboundQuantity(product)
}
if (props.field === 'unit') {
const unitRaw = product.unit || ''
if (!unitRaw) return '—'
@@ -9,6 +9,8 @@
:products="planProductRows"
show-actual-qty
show-brand-qty
:show-archive-io-fields="showArchiveIoFields"
:show-inbound-outbound-qty="!showArchiveIoFields"
/>
</section>
@@ -382,6 +384,8 @@
const props = withDefaults(
defineProps<{
feedbackEditable?: boolean
/** 仅运输计划归档:展示入库/出库单价与金额 */
showArchiveIoFields?: boolean
loading?: boolean
canEditDriverFeedback?: boolean
hasDriverFeedback?: boolean
@@ -402,6 +406,7 @@
}>(),
{
feedbackEditable: false,
showArchiveIoFields: false,
loading: false,
canEditDriverFeedback: false,
hasDriverFeedback: false,
@@ -38,6 +38,8 @@
:show-arrange-links="showArrangeActions"
:arrange-driver-url="driverUrl"
:arrange-site-records="siteRecords"
:show-archive-io-fields="showArchiveIoFields"
:show-inbound-outbound-qty="showInboundOutboundQty"
/>
<DbPoundSlipQrcodeDialog
@@ -60,6 +62,10 @@ import { usePlPlanArrangeActions } from '@/composables/usePlPlanArrangeActions'
const props = defineProps<{
visible: boolean
planId?: string
/** 仅运输计划归档:展示入库/出库单价与金额 */
showArchiveIoFields?: boolean
/** 计划概况产品表:展示入库/出库数量(不含单价与金额) */
showInboundOutboundQty?: boolean
}>()
const emit = defineEmits<{
@@ -15,7 +15,14 @@
<section class="pl-plan-form-section pl-plan-form-section--plain">
<h3 class="pl-plan-form-section-title">产品信息</h3>
<p v-if="!productRows.length && !loading" class="pl-plan-form-empty-hint">暂无产品明细</p>
<PlPlanFormProductTable v-else :products="productRows" show-actual-qty show-brand-qty />
<PlPlanFormProductTable
v-else
:products="productRows"
show-actual-qty
show-brand-qty
:show-archive-io-fields="showArchiveIoFields"
:show-inbound-outbound-qty="showInboundOutboundQty && !showArchiveIoFields"
/>
</section>
<section class="pl-plan-form-section pl-plan-form-section--plain">
@@ -431,6 +438,10 @@
hideNav?: boolean
/** 受控当前 Tab:overview / transport */
tab?: string
/** 仅运输计划归档:展示入库/出库单价与金额 */
showArchiveIoFields?: boolean
/** 计划概况产品表:展示入库/出库数量(不含单价与金额) */
showInboundOutboundQty?: boolean
}>(),
{
embedded: false,
@@ -440,7 +451,9 @@
feedbackEditable: false,
feedbackMirrorTo: null,
hideNav: false,
tab: undefined
tab: undefined,
showArchiveIoFields: false,
showInboundOutboundQty: false
}
)
@@ -1135,6 +1148,7 @@
const feedbackBlocksBind = computed(() => ({
feedbackEditable: props.feedbackEditable,
showArchiveIoFields: props.showArchiveIoFields,
loading: loading.value,
canEditDriverFeedback: canEditDriverFeedback.value,
hasDriverFeedback: hasDriverFeedback.value,