This commit is contained in:
zlwu committed 2026-08-27 19:38:01 +08:00
1 parent 4611dbc2b2
commit ebf006eeff
46 files changed
+36802 -20974

No files matched your search

+16 -1
View File
@@ -441,6 +441,8 @@ export interface PlanStopProduct {
outboundQty?: number
outboundPrice?: number
outboundAmount?: number
/** 本段归档品牌数量(与归档弹窗填写一致) */
brandQtys?: Array<{ brandId: string; brandName?: string; qty: number; unitPrice?: number }>
remark?: string
}
@@ -623,6 +625,8 @@ export interface PlanQuery {
hasHazardProduct?: number
/** incomplete=至少一张必需危化证书未上传 */
certUploadStatus?: string
/** 0=未确认齐备 1=已确认齐备 */
hazardCertReady?: number
page?: number
pageSize?: number
}
@@ -727,6 +731,8 @@ export interface PlanArchiveBrandQtyItem {
brandId: string
brandName?: string
qty: number
/** 本品牌单价(金额 = qty * unitPrice) */
unitPrice?: number
}
export interface TransportDriverProductFeedback {
@@ -760,6 +766,7 @@ export interface PlanArchiveStopProductItem {
inboundPrice?: number
outboundQty?: number
outboundPrice?: number
brandQtys?: PlanArchiveBrandQtyItem[]
}
export interface PlanArchiveLoadBlock {
@@ -826,6 +833,10 @@ export interface PlanContractGapItem {
supplierContracts: PlanContractLink[]
demanderContracts: PlanContractLink[]
products?: PlanProduct[]
/** 段次合同完善:装货段 */
load?: PlanLoad
/** 段次合同完善:送货段列表 */
deliveries?: PlanDelivery[]
}
export interface PlanContractGapQuery {
@@ -919,7 +930,11 @@ export const planApi = {
exportContractGaps: (data: Record<string, any>) =>
request.post('/transport/plan/contract-gaps/export', data, { responseType: 'blob' }),
updateSideContracts: (id: string, side: 'supplier' | 'demander', contracts: PlanContractLink[]) =>
request.put<PlanContractGapItem>(`/transport/plan/${id}/contracts/${side}`, { contracts })
request.put<PlanContractGapItem>(`/transport/plan/${id}/contracts/${side}`, { contracts }),
updateStopContracts: (
id: string,
data: { stopType: 'load' | 'delivery'; stopId: string; contracts: PlanContractLink[] }
) => request.put<PlanContractGapItem>(`/transport/plan/${id}/stop-contracts`, data)
}
// =============================================================================
@@ -159,7 +159,7 @@
</template>
</el-dialog>
<ContractDetailDialog v-if="contractDetailDialogActive" ref="contractDetailRef" hide-product-price />
<ContractDetailDialog ref="contractDetailRef" hide-product-price />
</template>
<script setup lang="ts">
@@ -220,7 +220,6 @@ const contractList = ref<ContractVO[]>([])
const currentRow = ref<ContractVO | null>(null)
const selectedId = ref('')
const planCountMap = ref<Record<string, number>>({})
const contractDetailDialogActive = ref(false)
const contractDetailRef = ref<InstanceType<typeof ContractDetailDialog>>()
const queryParams = reactive<ContractQuery>({
@@ -265,15 +264,32 @@ function formatContractProductLine(product: ContractProductForm) {
)
}
async function waitForContractDetailDialog() {
if (contractDetailRef.value) return contractDetailRef.value
await nextTick()
if (contractDetailRef.value) return contractDetailRef.value
return new Promise<NonNullable<typeof contractDetailRef.value>>((resolve) => {
const stop = watch(
contractDetailRef,
(val) => {
if (val) {
stop()
resolve(val)
}
},
{ flush: 'post' }
)
})
}
async function handleViewContract(row: ContractVO) {
const contractId = row?.id
if (!contractId) {
ElMessage.warning('合同信息无效')
return
}
contractDetailDialogActive.value = true
await nextTick()
contractDetailRef.value?.open(contractId)
const dialog = await waitForContractDetailDialog()
await dialog.open(contractId)
}
function isContractSelectable(row: ContractVO): boolean {
File diff suppressed because it is too large. Load diff
@@ -147,11 +147,7 @@
@confirm-multiple="handleConfirmMultiple"
/>
<ContractDetailDialog
v-if="contractDetailDialogActive"
ref="contractDetailRef"
hide-product-price
/>
<ContractDetailDialog ref="contractDetailRef" hide-product-price />
</div>
</template>
@@ -202,7 +198,6 @@ const emit = defineEmits<{
const selectorVisible = ref(false)
const detailRows = ref<ContractVO[]>([])
const loadingDetails = ref(false)
const contractDetailDialogActive = ref(false)
const contractDetailRef = ref<InstanceType<typeof ContractDetailDialog>>()
const expandedContractIds = ref<Set<string>>(new Set())
@@ -344,6 +339,24 @@ function removeContract(id: string) {
emitLinksFromDetails(next)
}
async function waitForContractDetailDialog() {
if (contractDetailRef.value) return contractDetailRef.value
await nextTick()
if (contractDetailRef.value) return contractDetailRef.value
return new Promise<NonNullable<typeof contractDetailRef.value>>((resolve) => {
const stop = watch(
contractDetailRef,
(val) => {
if (val) {
stop()
resolve(val)
}
},
{ flush: 'post' }
)
})
}
async function handleViewContract(contractId: string) {
const id = String(contractId || '').trim()
if (!id) {
@@ -351,9 +364,8 @@ async function handleViewContract(contractId: string) {
return
}
emit('view', id)
contractDetailDialogActive.value = true
await nextTick()
contractDetailRef.value?.open(id)
const dialog = await waitForContractDetailDialog()
await dialog.open(id)
}
defineExpose({
@@ -1,8 +1,20 @@
<template>
<div class="panel dialog-list-panel pl-plan-product-table-wrap">
<div class="list-body list-wrap dialog-list-body-only">
<ListTable table-key="business-pl-plan-form-dialog-pl-plan-form-product-table" :data="products" stripe class="list-table pl-plan-product-table" empty-text="暂无产品">
<el-table-column label="序号" type="index" width="60" align="center" header-align="center" />
<ListTable
table-key="business-pl-plan-form-dialog-pl-plan-form-product-table"
:data="products"
stripe
class="list-table pl-plan-product-table"
empty-text="暂无产品"
>
<el-table-column
label="序号"
type="index"
width="60"
align="center"
header-align="center"
/>
<el-table-column label="产品名称" min-width="140" show-overflow-tooltip>
<template #default="{ row }">
<PlProductNameHazardCell
@@ -10,7 +22,13 @@
/>
</template>
</el-table-column>
<el-table-column label="别名" min-width="140" align="center" show-overflow-tooltip>
<el-table-column
v-if="!archiveCompact"
label="别名"
min-width="140"
align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<div v-if="editableAliasBrand" class="line-meta-cell">
<el-select
@@ -44,7 +62,13 @@
<span v-else>{{ row.aliasName || '—' }}</span>
</template>
</el-table-column>
<el-table-column label="品牌" min-width="140" align="center" show-overflow-tooltip>
<el-table-column
v-if="!archiveCompact"
label="品牌"
min-width="140"
align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<div v-if="editableAliasBrand" class="line-meta-cell">
<el-select
@@ -78,7 +102,12 @@
<span v-else>{{ formatBrandConstraintLabel(row.brandMode, row.brandName) }}</span>
</template>
</el-table-column>
<el-table-column label="型号/规格" min-width="120" show-overflow-tooltip>
<el-table-column
v-if="!archiveCompact"
label="型号/规格"
min-width="120"
show-overflow-tooltip
>
<template #default="{ row }">
{{ row.model || row.productModel || '—' }}
</template>
@@ -217,17 +246,18 @@
{{ formatPlanProductIoAmount(row.outboundQty, row.outboundPrice) }}
</template>
</el-table-column>
<el-table-column
v-if="showBrandQty"
label="品牌数量"
min-width="180"
show-overflow-tooltip
>
<el-table-column v-if="showBrandQty" label="品牌数量" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
{{ formatBrandQtySummary(row.brandQtyRows) || '—' }}
</template>
</el-table-column>
<el-table-column prop="packageSpec" label="包装规格" min-width="150" align="center">
<el-table-column
v-if="!archiveCompact"
prop="packageSpec"
label="包装规格"
min-width="150"
align="center"
>
<template #default="{ row }">
<DictSelect
v-if="editablePackageDelivery"
@@ -243,7 +273,7 @@
</template>
</template>
</el-table-column>
<el-table-column label="送货方式" min-width="150" align="center">
<el-table-column v-if="!archiveCompact" label="送货方式" min-width="150" align="center">
<template #default="{ row }">
<DictSelect
v-if="editablePackageDelivery"
@@ -263,7 +293,11 @@
</el-table-column>
<el-table-column v-if="editableRemark" label="备注" min-width="140">
<template #default="{ row }">
<el-input v-model="row.remark" class="pl-plan-product-cell-control" placeholder="备注" />
<el-input
v-model="row.remark"
class="pl-plan-product-cell-control"
placeholder="备注"
/>
</template>
</el-table-column>
<el-table-column v-else prop="remark" label="备注" min-width="120" show-overflow-tooltip />
@@ -308,172 +342,166 @@
</template>
<script setup lang="ts">
import { Delete, Plus } from '@element-plus/icons-vue'
import type { PlanFormProductRow } from '@/composables/usePlContractToPlan'
import { getDeliveryMethodLabel } from '@/composables/usePlContractToPlan'
import {
applyBrandSelectChange,
applyManagedProductMetaToLines,
formatBrandConstraintLabel,
resolveLineProductName
} from '@/utils/crm-product-brand'
import { formatBrandQtySummary } from '@/utils/pl-display'
import PlProductNameHazardCell from '@/components/business/pl-product-name-hazard-cell/index.vue'
import ProductAliasBrandDialog from '@/components/business/product-alias-brand-dialog/index.vue'
import DictTag from '@/components/DictTag/index.vue'
import DictSelect from '@/components/DictSelect/index.vue'
import { useDictStore } from '@/store/modules/dict'
import { Delete, Plus } from '@element-plus/icons-vue'
import type { PlanFormProductRow } from '@/composables/usePlContractToPlan'
import { getDeliveryMethodLabel } from '@/composables/usePlContractToPlan'
import {
applyBrandSelectChange,
applyManagedProductMetaToLines,
formatBrandConstraintLabel,
resolveLineProductName
} from '@/utils/crm-product-brand'
import { formatBrandQtySummary } from '@/utils/pl-display'
import PlProductNameHazardCell from '@/components/business/pl-product-name-hazard-cell/index.vue'
import ProductAliasBrandDialog from '@/components/business/product-alias-brand-dialog/index.vue'
import DictTag from '@/components/DictTag/index.vue'
import DictSelect from '@/components/DictSelect/index.vue'
import { useDictStore } from '@/store/modules/dict'
const props = defineProps<{
products: PlanFormProductRow[]
showDelete?: boolean
showSelect?: boolean
/** 已加入运输计划的产品 ID,用于禁用合同预览中的「选择」按钮 */
selectedProductIds?: string[]
editableRemark?: boolean
editableQty?: boolean
/** 产品信息区:别名 / 品牌可改 */
editableAliasBrand?: boolean
/** 单位 / 包装规格 / 送货方式可下拉编辑(对齐报价单) */
editablePackageDelivery?: boolean
/** 新增/编辑弹窗默认不展示实际数量 */
showActualQty?: boolean
/** 展示归档品牌数量摘要(如:君正 12 / 红四方 13) */
showBrandQty?: boolean
/** 仅实际数量可编辑,计划数量只读(归档弹窗) */
editableActualQtyOnly?: boolean
/** 展示入库/出库数量列(不含价格) */
showInboundOutboundQty?: boolean
/** 展示入库/出库数量、单价与计算金额列 */
showArchiveIoFields?: boolean
/** 入库/出库数量与价格可编辑(归档弹窗) */
editableArchiveIoFields?: boolean
/** 归档:仅展示/编辑入库列(装货段) */
archiveIoInboundOnly?: boolean
/** 归档:仅展示/编辑出库列(送货段) */
archiveIoOutboundOnly?: boolean
/** 段次归档:入库/出库列统一展示为实际数量、单价、金额 */
archiveSegmentLabels?: boolean
}>()
const props = defineProps<{
products: PlanFormProductRow[]
showDelete?: boolean
showSelect?: boolean
/** 已加入运输计划的产品 ID,用于禁用合同预览中的「选择」按钮 */
selectedProductIds?: string[]
editableRemark?: boolean
editableQty?: boolean
/** 产品信息区:别名 / 品牌可改 */
editableAliasBrand?: boolean
/** 单位 / 包装规格 / 送货方式可下拉编辑(对齐报价单) */
editablePackageDelivery?: boolean
/** 新增/编辑弹窗默认不展示实际数量 */
showActualQty?: boolean
/** 展示归档品牌数量摘要(如:君正 12 / 红四方 13) */
showBrandQty?: boolean
/** 仅实际数量可编辑,计划数量只读(归档弹窗) */
editableActualQtyOnly?: boolean
/** 展示入库/出库数量列(不含价格) */
showInboundOutboundQty?: boolean
/** 展示入库/出库数量、单价与计算金额列 */
showArchiveIoFields?: boolean
/** 入库/出库数量与价格可编辑(归档弹窗) */
editableArchiveIoFields?: boolean
/** 归档:仅展示/编辑入库列(装货段) */
archiveIoInboundOnly?: boolean
/** 归档:仅展示/编辑出库列(送货段) */
archiveIoOutboundOnly?: boolean
/** 段次归档:入库/出库列统一展示为实际数量、单价、金额 */
archiveSegmentLabels?: boolean
/** 归档段表单:仅保留产品名称、单位、计划数量与归档列 */
archiveCompact?: boolean
}>()
const emit = defineEmits<{
remove: [index: number]
select: [row: PlanFormProductRow, index: number]
}>()
const emit = defineEmits<{
remove: [index: number]
select: [row: PlanFormProductRow, index: number]
}>()
const dictStore = useDictStore()
const dictStore = useDictStore()
const selectedProductIdSet = computed(
() => new Set((props.selectedProductIds || []).map(id => String(id)))
)
function getDeliveryMethodDisplay(method?: string) {
const label = dictStore.getLabel('crm_shfs', method || '')
if (label && label !== method) return label
return getDeliveryMethodLabel(method)
}
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)
}
function handleBrandChange(row: PlanFormProductRow, val: string | undefined | null) {
applyBrandSelectChange(row, val, row.brandOptions || [])
}
const aliasBrandManageVisible = ref(false)
const aliasBrandManageProductId = ref('')
const aliasBrandManageProductName = ref('')
const aliasBrandManageMode = ref<'alias' | 'brand'>('alias')
function openAliasBrandManage(row: PlanFormProductRow, mode: 'alias' | 'brand') {
if (!row.productId) return
aliasBrandManageProductId.value = String(row.productId)
aliasBrandManageProductName.value = String(row.standardProductName || row.productName || '')
aliasBrandManageMode.value = mode
aliasBrandManageVisible.value = true
}
function handleAliasBrandManaged(product: Record<string, any>) {
applyManagedProductMetaToLines(
props.products || [],
aliasBrandManageProductId.value,
product
const selectedProductIdSet = computed(
() => new Set((props.selectedProductIds || []).map((id) => String(id)))
)
const pid = aliasBrandManageProductId.value
for (const line of props.products || []) {
if (String(line.productId || '') !== pid) continue
line.productName = resolveLineProductName(line.standardProductName, line.aliasName)
function getDeliveryMethodDisplay(method?: string) {
const label = dictStore.getLabel('crm_shfs', method || '')
if (label && label !== method) return label
return getDeliveryMethodLabel(method)
}
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)
}
function handleBrandChange(row: PlanFormProductRow, val: string | undefined | null) {
applyBrandSelectChange(row, val, row.brandOptions || [])
}
const aliasBrandManageVisible = ref(false)
const aliasBrandManageProductId = ref('')
const aliasBrandManageProductName = ref('')
const aliasBrandManageMode = ref<'alias' | 'brand'>('alias')
function openAliasBrandManage(row: PlanFormProductRow, mode: 'alias' | 'brand') {
if (!row.productId) return
aliasBrandManageProductId.value = String(row.productId)
aliasBrandManageProductName.value = String(row.standardProductName || row.productName || '')
aliasBrandManageMode.value = mode
aliasBrandManageVisible.value = true
}
function handleAliasBrandManaged(product: Record<string, any>) {
applyManagedProductMetaToLines(props.products || [], aliasBrandManageProductId.value, product)
const pid = aliasBrandManageProductId.value
for (const line of props.products || []) {
if (String(line.productId || '') !== pid) continue
line.productName = resolveLineProductName(line.standardProductName, line.aliasName)
}
}
}
</script>
<style scoped lang="scss">
.pl-plan-product-table-wrap {
margin-top: 8px;
.pl-plan-product-table-wrap {
margin-top: 8px;
/* 别名 / 品牌下拉与备注输入框同高、同字号 */
:deep(.pl-plan-product-cell-control) {
.el-input__wrapper,
.el-select__wrapper {
height: var(--el-component-custom-height, 36px);
min-height: var(--el-component-custom-height, 36px);
font-size: 14px;
/* 别名 / 品牌下拉与备注输入框同高、同字号 */
:deep(.pl-plan-product-cell-control) {
.el-input__wrapper,
.el-select__wrapper {
height: var(--el-component-custom-height, 36px);
min-height: var(--el-component-custom-height, 36px);
font-size: 14px;
}
.el-input__inner,
.el-select__selected-item,
.el-select__placeholder,
.el-select__input {
font-size: 14px;
}
}
.el-input__inner,
.el-select__selected-item,
.el-select__placeholder,
.el-select__input {
font-size: 14px;
.line-meta-cell {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.line-meta-cell__control {
flex: 1;
min-width: 0;
}
.line-meta-cell__empty {
flex: 1;
min-width: 0;
}
.line-meta-cell__btn {
flex-shrink: 0;
padding: 4px;
}
}
.line-meta-cell {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.line-meta-cell__control {
flex: 1;
min-width: 0;
}
.line-meta-cell__empty {
flex: 1;
min-width: 0;
}
.line-meta-cell__btn {
flex-shrink: 0;
padding: 4px;
}
}
</style>
<style lang="scss">
/* teleported 下拉挂到 body,需非 scoped;选项字号与输入框一致 */
.pl-plan-alias-brand-select-dropdown {
.el-select-dropdown__item {
font-size: 14px;
/* teleported 下拉挂到 body,需非 scoped;选项字号与输入框一致 */
.pl-plan-alias-brand-select-dropdown {
.el-select-dropdown__item {
font-size: 14px;
}
}
}
</style>
@@ -13,7 +13,9 @@
field === 'planQty' ||
field === 'actualQty' ||
field === 'inboundQty' ||
field === 'outboundQty',
field === 'outboundQty' ||
field === 'unitPrice' ||
field === 'amount',
'pl-plan-list-product-cell__line--center':
field === 'spec' || field === 'model' || field === 'packageSpec' || field === 'unit'
}"
@@ -64,7 +66,9 @@
</span>
</template>
<template v-else-if="field === 'name'">
<span class="pl-plan-list-product-cell__text">{{ product.productName || '—' }}</span>
<span class="pl-plan-list-product-cell__text">{{
resolveListProductName(product)
}}</span>
<el-tag
v-if="getHazardFlags(product).isToxic"
type="warning"
@@ -109,6 +113,13 @@
getPlanProductQuantity,
getPlanProductSpec
} from '@/utils/pl-display'
import {
getStopArchiveActualQty,
getStopArchiveAmount,
getStopArchiveUnitPrice,
resolveArchiveListIoColumn,
type PlPlanListDisplayRowKind
} from '@/utils/pl-plan-list-display-rows'
import { computed } from 'vue'
export type PlPlanListProductCellField =
@@ -121,6 +132,8 @@
| 'actualQty'
| 'inboundQty'
| 'outboundQty'
| 'unitPrice'
| 'amount'
| 'unit'
const props = withDefaults(
@@ -134,12 +147,18 @@
field?: PlPlanListProductCellField
/** 危化品行下方展示证书有无(按产品类型;仅整行模式) */
showCertStatus?: boolean
/** 运输计划归档列表:段次数量/入出库/单价/金额展示规则 */
archiveListMode?: boolean
stopKind?: PlPlanListDisplayRowKind
stopCustomerName?: string
}>(),
{
products: () => [],
stopProducts: () => [],
planProducts: () => [],
showCertStatus: false
showCertStatus: false,
archiveListMode: false,
stopCustomerName: ''
}
)
@@ -168,6 +187,19 @@
)
}
/** 列表「产品名称」列:只展示标准产品名,不展示别名 */
function resolveListProductName(product: PlanProduct | PlanStopProduct) {
if (useStopProducts.value) {
const stopProduct = product as PlanStopProduct
const linked = resolveLinkedPlanProduct(stopProduct)
const linkedName = (linked?.productName || '').trim()
if (linkedName) return linkedName
return stopProduct.productName || '—'
}
const planProduct = product as PlanProduct
return planProduct.productName || '—'
}
function formatStopProductLine(stopProduct: PlanStopProduct) {
const linked = resolveLinkedPlanProduct(stopProduct)
if (linked) return formatPlanProductListText(linked, getUnitLabel)
@@ -180,14 +212,25 @@
return [stopProduct.productName || '—', model, qty].filter(Boolean).join('')
}
function formatStopQty(value: unknown, unitRaw?: string) {
function formatStopQty(value: unknown, unitRaw?: string, omitUnit = false) {
if (value == null || value === '') return '—'
const num = Number(value)
if (!Number.isFinite(num)) return '—'
if (omitUnit) return String(num)
const unit = getUnitLabel(unitRaw || '') || unitRaw || ''
return unit ? `${num} ${unit}` : String(num)
}
function formatArchiveQty(value: number | null) {
if (value == null) return '—'
return String(value)
}
function formatMoneyValue(value: number | null) {
if (value == null) return '—'
return value.toFixed(2)
}
function formatProductLine(product: PlanProduct) {
return formatPlanProductListText(product, getUnitLabel)
}
@@ -196,6 +239,29 @@
if (useStopProducts.value) {
const stopProduct = product as PlanStopProduct
const linked = resolveLinkedPlanProduct(stopProduct)
const stopKind = props.stopKind || 'load'
if (props.archiveListMode) {
const ioColumn = resolveArchiveListIoColumn(stopKind, props.stopCustomerName)
const actualQty = getStopArchiveActualQty(stopProduct, stopKind)
if (props.field === 'actualQty') {
return formatArchiveQty(actualQty)
}
if (props.field === 'inboundQty') {
return ioColumn === 'inbound' ? formatArchiveQty(actualQty) : '—'
}
if (props.field === 'outboundQty') {
return ioColumn === 'outbound' ? formatArchiveQty(actualQty) : '—'
}
if (props.field === 'unitPrice') {
return formatMoneyValue(getStopArchiveUnitPrice(stopProduct, stopKind))
}
if (props.field === 'amount') {
return formatMoneyValue(getStopArchiveAmount(stopProduct, stopKind))
}
}
if (props.field === 'spec' || props.field === 'model') {
return (
stopProduct.model || linked?.model || (linked ? getPlanProductSpec(linked) : '—') || '—'
@@ -214,6 +280,10 @@
return formatStopQty(stopProduct.outboundQty, stopProduct.unit)
}
if (props.field === 'actualQty') {
const segmentKind = props.stopKind || (props.archiveListMode ? 'load' : undefined)
if (props.archiveListMode || segmentKind) {
return formatArchiveQty(getStopArchiveActualQty(stopProduct, segmentKind || 'load'))
}
return linked ? getPlanProductActualQuantity(linked) : '—'
}
if (props.field === 'unit') {
@@ -249,6 +319,9 @@
if (props.field === 'outboundQty') {
return getPlanProductOutboundQuantity(planProduct)
}
if (props.field === 'unitPrice' || props.field === 'amount') {
return '—'
}
if (props.field === 'unit') {
const unitRaw = planProduct.unit || ''
if (!unitRaw) return '—'
@@ -13,10 +13,7 @@
<section class="pl-plan-form-section pl-plan-form-section--plain">
<h3 class="pl-plan-form-section-title">运输信息</h3>
<div
v-if="showArrangeLinks"
class="pl-plan-arrange-links pl-plan-transport-block"
>
<div v-if="showArrangeLinks" class="pl-plan-arrange-links pl-plan-transport-block">
<div class="pl-arrange-links-body">
<div class="pl-arrange-link-row">
<span class="pl-arrange-link-label">司机链接</span>
@@ -123,6 +120,10 @@
stop-archive-mode="load"
/>
<p v-else class="pl-plan-form-empty-hint">暂无装货产品</p>
<PlPlanArchiveBrandViewBlock
v-if="showArchiveIoFields"
:items="loadArchiveBrandItems"
/>
<PlPlanStopFeedbackPanel
v-if="showStopFeedback && !feedbackMirrorTo"
stop-label="装货"
@@ -136,7 +137,10 @@
</div>
</template>
<template #delivery>
<div v-if="deliveryViews.length > 1" class="pl-plan-delivery-tabs pl-plan-view-delivery-tabs">
<div
v-if="deliveryViews.length > 1"
class="pl-plan-delivery-tabs pl-plan-view-delivery-tabs"
>
<button
v-for="(delivery, index) in deliveryViews"
:key="delivery.id || 'delivery-tab-' + index"
@@ -199,6 +203,10 @@
stop-archive-mode="delivery"
/>
<p v-else class="pl-plan-form-empty-hint">暂无送货产品</p>
<PlPlanArchiveBrandViewBlock
v-if="showArchiveIoFields"
:items="deliveryArchiveBrandItems(delivery, index)"
/>
<PlPlanStopFeedbackPanel
v-if="showStopFeedback && !feedbackMirrorTo"
:stop-label="
@@ -360,7 +368,12 @@
</div>
</el-form-item>
<el-form-item label="现场备注">
<el-input v-model="ioOrderEditForm.siteFeedbackRemark" type="textarea" :rows="3" maxlength="500" />
<el-input
v-model="ioOrderEditForm.siteFeedbackRemark"
type="textarea"
:rows="3"
maxlength="500"
/>
</el-form-item>
<el-form-item label="现场附件">
<AttachmentUploadField v-model="ioOrderEditImagesCsv" />
@@ -392,6 +405,7 @@
} from '@/components/business/brand-qty-chips-field/index.vue'
import PlPlanDriverFeedbackDialog from '@/components/business/pl-plan-driver-feedback-dialog/index.vue'
import PlPlanHazardCertViewTable from '@/components/business/pl-plan-hazard-cert-view-table/index.vue'
import PlPlanArchiveBrandViewBlock from '@/components/business/pl-plan-archive-brand-view-block/index.vue'
import PlPlanStopFeedbackPanel from '@/components/business/pl-plan-stop-feedback-panel/index.vue'
import PlPlanStopProductTable from '@/components/business/pl-plan-stop-product-table/index.vue'
import PlPlanStopsSplitShell from '@/components/business/pl-plan-stops-split-shell/index.vue'
@@ -433,6 +447,7 @@
type PlanLoadForm,
type PlanStopProductFormRow
} from '@/utils/pl-plan-stop-form'
import { buildArchiveBrandViewsBySegment } from '@/utils/pl-plan-archive-brand-view'
import { resolveFreightUnitLabel } from '@/composables/usePlFreightCalc'
import { isHazardProduct } from '@/composables/usePlToxicProductCertCheck'
import { useDictStore } from '@/store/modules/dict'
@@ -579,8 +594,7 @@
{
label: '状态',
value: statusMap[status] || status,
tagType:
status === 'completed' ? 'success' : status === 'voided' ? 'info' : 'warning'
tagType: status === 'completed' ? 'success' : status === 'voided' ? 'info' : 'warning'
},
{ label: '计划号', value: ioOrder.value.planNo },
{ label: '车牌', value: ioOrder.value.vehicleNo },
@@ -608,18 +622,15 @@
it.brandId &&
!options.some((o) => o.brandId === it.brandId)
) {
options = [
{ brandId: it.brandId, brandName: it.brandName || '指定品牌' },
...options
]
options = [{ brandId: it.brandId, brandName: it.brandName || '指定品牌' }, ...options]
}
return options
}
function mapIoItemBrandQtyRows(it: PlIoOrder['items'][number]): BrandQtyRow[] {
const saved = (it.brandQtys || [])
.filter(r => Number(r.qty) > 0)
.map(r => ({
.filter((r) => Number(r.qty) > 0)
.map((r) => ({
brandId: r.brandId,
brandName: r.brandName || '',
qty: Number(r.qty)
@@ -639,7 +650,7 @@
]
}
if (it.brandMode === 'any') {
return (it.brandOptions || []).map(opt => ({
return (it.brandOptions || []).map((opt) => ({
brandId: opt.brandId,
brandName: opt.brandName || '',
qty: 0
@@ -650,16 +661,14 @@
function openIoOrderEditDialog() {
if (!ioOrder.value) return
const planProductById = new Map((products.value || []).map(p => [p.id, p]))
const planProductById = new Map((products.value || []).map((p) => [p.id, p]))
ioOrderEditForm.value = {
...ioOrder.value,
items: (ioOrder.value.items || []).map(it => {
items: (ioOrder.value.items || []).map((it) => {
const brandOptions = resolveIoBrandOptions(it)
const brandQtyRows = mapIoItemBrandQtyRows({ ...it, brandOptions })
const actualQty = it.brandMode
? Number(
brandQtyRows.reduce((sum, r) => sum + Number(r.qty || 0), 0).toFixed(3)
)
? Number(brandQtyRows.reduce((sum, r) => sum + Number(r.qty || 0), 0).toFixed(3))
: it.actualQty
const planProduct = planProductById.get(it.planProductId)
return {
@@ -684,7 +693,7 @@
for (const it of items) {
if (!it.brandMode) continue
const rows = it.brandQtyRows || []
const positive = rows.filter(r => Number(r.qty) > 0)
const positive = rows.filter((r) => Number(r.qty) > 0)
if (!positive.length) {
ElMessage.warning(`「${it.productName || '产品'}」请至少填写一个品牌数量`)
return
@@ -699,18 +708,21 @@
ioOrderSaving.value = true
try {
const images = ioOrderEditImagesCsv.value
? ioOrderEditImagesCsv.value.split(',').map(s => s.trim()).filter(Boolean)
? ioOrderEditImagesCsv.value
.split(',')
.map((s) => s.trim())
.filter(Boolean)
: []
await api.patch(ioOrderEditForm.value.id, {
siteFeedbackRemark: ioOrderEditForm.value.siteFeedbackRemark,
siteFeedbackImages: images,
items: items.map(it => ({
items: items.map((it) => ({
id: it.id,
actualQty: Number(it.actualQty ?? 0),
brandQtys: it.brandMode
? (it.brandQtyRows || [])
.filter(r => Number(r.qty) > 0)
.map(r => ({
.filter((r) => Number(r.qty) > 0)
.map((r) => ({
brandId: r.brandId,
brandName: r.brandName,
qty: Number(r.qty)
@@ -777,9 +789,13 @@
productRows.value = enriched
}
watch([products, contractSnapshot], () => {
void refreshProductRows()
}, { immediate: true, deep: true })
watch(
[products, contractSnapshot],
() => {
void refreshProductRows()
},
{ immediate: true, deep: true }
)
const planProductById = computed(() => {
const map = new Map<string, (typeof products.value)[number]>()
@@ -797,7 +813,9 @@
return map
})
function resolveFeedbackPlanProductId(row: TransportDriverProductFeedback | Record<string, unknown>) {
function resolveFeedbackPlanProductId(
row: TransportDriverProductFeedback | Record<string, unknown>
) {
const raw =
(row as TransportDriverProductFeedback).planProductId ||
(row as { plan_product_id?: string }).plan_product_id ||
@@ -873,7 +891,9 @@
const fb = feedbackById.get(String(pp.id))
if (!fb) continue
usedIds.add(String(pp.id))
rows.push(buildDriverFeedbackDisplayRow(pp, fb, planProductFormById.value.get(String(pp.id))))
rows.push(
buildDriverFeedbackDisplayRow(pp, fb, planProductFormById.value.get(String(pp.id)))
)
}
// 兼容:planProductId 对不上时按序号兜底
@@ -891,7 +911,9 @@
const id = resolveFeedbackPlanProductId(fb)
if (id && usedIds.has(id)) continue
const pp = id ? planProductById.value.get(id) : undefined
rows.push(buildDriverFeedbackDisplayRow(pp, fb, id ? planProductFormById.value.get(id) : undefined))
rows.push(
buildDriverFeedbackDisplayRow(pp, fb, id ? planProductFormById.value.get(id) : undefined)
)
}
return rows
}
@@ -963,8 +985,7 @@
String(formRow?.productNo || '').trim(),
String(pp?.productNo || '').trim()
].filter(Boolean)
const productNo =
candidates.find((no) => no && no !== productId) || candidates[0] || ''
const productNo = candidates.find((no) => no && no !== productId) || candidates[0] || ''
const brandLabel = resolveDisplayBrandLabel({
brandMode: it.brandMode || pp?.brandMode || formRow?.brandMode,
brandName: it.brandName || pp?.brandName || formRow?.brandName
@@ -986,10 +1007,7 @@
remark: (it.remark || pp?.remark || formRow?.remark || '').trim(),
unit: unitCode || it.unit,
unitLabel:
it.unitLabel ||
dictStore.getLabel('crm_product_unit', unitCode) ||
unitCode ||
''
it.unitLabel || dictStore.getLabel('crm_product_unit', unitCode) || unitCode || ''
}
})
})
@@ -1006,7 +1024,7 @@
)
const dibangPlanRows = computed(() =>
dibangRows.value.filter(row => Boolean(row.link.dbPurchasePlanId?.trim()))
dibangRows.value.filter((row) => Boolean(row.link.dbPurchasePlanId?.trim()))
)
const siteInventoryRows = computed(() => siteRows.value)
@@ -1169,11 +1187,15 @@
ElMessage.warning('暂无现场链接')
return
}
const product = products.value.find(item => item.id === row.link.planProductId)
const product = products.value.find((item) => item.id === row.link.planProductId)
const productText = product
? formatPlanProductListText(product, unit => dictStore.getLabel('crm_product_unit', unit) || unit, {
usePlannedQuantity: true
})
? formatPlanProductListText(
product,
(unit) => dictStore.getLabel('crm_product_unit', unit) || unit,
{
usePlannedQuantity: true
}
)
: row.link.productName || '—'
const text = buildSiteSummaryFromInventoryLinks({
planNo: row.link.planNo || plan.value?.planNo,
@@ -1212,12 +1234,14 @@
const deliveryViews = computed(() => {
if (plan.value?.deliveries?.length) {
return mapDeliveriesFromApi(plan.value.deliveries, plan.value.load?.customerName).map((row, index) => ({
id: plan.value?.deliveries?.[index]?.id,
...row,
address: resolveDeliveryViewAddress(plan.value, row, index),
products: resolveStopProductsForView(row.products, productRows.value)
}))
return mapDeliveriesFromApi(plan.value.deliveries, plan.value.load?.customerName).map(
(row, index) => ({
id: plan.value?.deliveries?.[index]?.id,
...row,
address: resolveDeliveryViewAddress(plan.value, row, index),
products: resolveStopProductsForView(row.products, productRows.value)
})
)
}
return mapLegacyPlanToStops(plan.value || ({} as any)).deliveries.map((row, index) => ({
id: undefined as string | undefined,
@@ -1227,6 +1251,23 @@
}))
})
const archiveBrandViewsBySegment = computed(() =>
buildArchiveBrandViewsBySegment({
loadProducts: loadView.value.products || [],
deliveries: deliveryViews.value.map((row, index) => ({
key: `delivery-${row.id || index}`,
products: row.products || []
})),
planProducts: productRows.value
})
)
const loadArchiveBrandItems = computed(() => archiveBrandViewsBySegment.value.get('load') || [])
function deliveryArchiveBrandItems(delivery: { id?: string }, index: number) {
return archiveBrandViewsBySegment.value.get(`delivery-${delivery.id || index}`) || []
}
watch(
() => deliveryViews.value.length,
(len) => {
@@ -1337,7 +1378,6 @@
() => plan.value?.transportStatus === 'arranged' && plan.value?.followStatus !== 'cancelled'
)
const feedbackBlocksBind = computed(() => ({
feedbackEditable: props.feedbackEditable,
showArchiveIoFields: props.showArchiveIoFields,
@@ -1405,10 +1445,7 @@
const qty = (transport.value as Transport & { transportQty?: number })?.transportQty
if (qty != null) return String(qty)
if (!productRows.value.length) return '—'
const sum = productRows.value.reduce(
(total, item) => total + Number(item.actualQty ?? 0),
0
)
const sum = productRows.value.reduce((total, item) => total + Number(item.actualQty ?? 0), 0)
return sum ? String(sum) : '—'
})
@@ -40,7 +40,11 @@ export function mapPlanProductToFormRow(item: PlanProduct, index: number): PlanF
.map(row => ({
brandId: String(row.brandId || ''),
brandName: String(row.brandName || ''),
qty: Number(row.qty || 0)
qty: Number(row.qty || 0),
price:
row.unitPrice != null && Number.isFinite(Number(row.unitPrice))
? Number(row.unitPrice)
: undefined
}))
.filter(row => Number.isFinite(row.qty) && row.qty > 0)
return {
@@ -28,8 +28,7 @@ function isTruthyHazardFlag(value: unknown) {
}
export function resolveProductHazardFlags(product: HazardFlagProduct) {
const isToxic =
isTruthyHazardFlag(product.isToxic) || isTruthyHazardFlag(product.is_toxic)
const isToxic = isTruthyHazardFlag(product.isToxic) || isTruthyHazardFlag(product.is_toxic)
const isExplosive =
isTruthyHazardFlag(product.isExplosive) || isTruthyHazardFlag(product.is_explosive)
return { isToxic, isExplosive }
@@ -70,7 +69,7 @@ export function getProductCertUrl(product: PlanProduct, key: HazardCertSlotKey):
export function splitProductCertUrls(raw?: string): string[] {
return String(raw || '')
.split(',')
.map(item => item.trim())
.map((item) => item.trim())
.filter(Boolean)
}
@@ -86,7 +85,7 @@ export function isProductCertSlotFilled(product: PlanProduct, key: HazardCertSlo
export function isProductCertComplete(product: PlanProduct): boolean {
const slots = getRequiredCertSlots(product)
if (!slots.length) return true
return slots.every(slot => isProductCertSlotFilled(product, slot.key))
return slots.every((slot) => isProductCertSlotFilled(product, slot.key))
}
/** 产品名称后追加易制毒/易制爆标识:毒、爆,或「毒 爆」 */
@@ -101,19 +100,17 @@ export function formatPlProductNameWithHazardMarks(product: HazardFlagProduct) {
}
/** 从 CRM 产品主数据读取易制毒/易制爆标识(覆盖计划产品快照字段) */
export async function enrichPlanProductsHazardFromCrm<T extends HazardFlagProduct & { productId: string }>(
products: T[]
): Promise<T[]> {
export async function enrichPlanProductsHazardFromCrm<
T extends HazardFlagProduct & { productId: string }
>(products: T[]): Promise<T[]> {
if (!products.length) return products
const lookupIds = [
...new Set(products.map(product => product.productId).filter(Boolean))
]
const lookupIds = [...new Set(products.map((product) => product.productId).filter(Boolean))]
if (!lookupIds.length) return products
const hazardMap = new Map<string, { isToxic: number; isExplosive: number; taxRate?: number }>()
await Promise.all(
lookupIds.map(async productId => {
lookupIds.map(async (productId) => {
try {
const res = await fetchProductDetail({ id: productId })
const data = (res as { data?: Record<string, unknown> }).data ?? res
@@ -130,7 +127,7 @@ export async function enrichPlanProductsHazardFromCrm<T extends HazardFlagProduc
})
)
return products.map(product => {
return products.map((product) => {
const fromCrm = hazardMap.get(product.productId)
if (!fromCrm) return product
return {
@@ -143,11 +140,11 @@ export async function enrichPlanProductsHazardFromCrm<T extends HazardFlagProduc
}
export function findMissingHazardCerts(products: PlanProduct[]) {
return products.filter(p => isHazardProduct(p) && !isProductCertComplete(p))
return products.filter((p) => isHazardProduct(p) && !isProductCertComplete(p))
}
export function planHasHazardProducts(products: PlanProduct[] | undefined) {
return (products ?? []).some(p => isHazardProduct(p))
return (products ?? []).some((p) => isHazardProduct(p))
}
export function isHazardCertReady(plan: { hazardCertReady?: number } | null | undefined) {
@@ -179,8 +176,8 @@ export function buildMissingHazardCertReadyMessage(
function formatMissingCertLabels(product: PlanProduct): string {
const missing = getRequiredCertSlots(product)
.filter(slot => !isProductCertSlotFilled(product, slot.key))
.map(slot => slot.label)
.filter((slot) => !isProductCertSlotFilled(product, slot.key))
.map((slot) => slot.label)
return missing.join('、')
}
@@ -188,15 +185,12 @@ export function buildMissingHazardCertMessage(products: PlanProduct[]) {
const missing = findMissingHazardCerts(products)
if (!missing.length) return ''
const details = missing
.map(p => `「${p.productName}」须补充:${formatMissingCertLabels(p)}`)
.map((p) => `「${p.productName}」须补充:${formatMissingCertLabels(p)}`)
.join(';')
return `以下产品证书尚未齐全:${details}`
}
export const HAZARD_CERT_REQUIREMENT_LINES = [
'易制毒:购买证、运输证',
'易制爆:合法证明'
] as const
export const HAZARD_CERT_REQUIREMENT_LINES = ['易制毒:购买证、运输证', '易制爆:合法证明'] as const
export const HAZARD_CERT_READY_CONFIRM_HINT =
'计划内产品仅分为易制毒、易制爆或普通产品,同一产品不会兼具两种危化属性。' +
@@ -213,3 +207,23 @@ export function buildHazardCertReadyConfirmMessage(): string {
'确认后可安排运输;之后仍可在本页补充上传附件。'
].join('<br>')
}
/** 批量证书齐备确认弹窗正文 */
export function buildBatchHazardCertReadyConfirmMessage(count: number): string {
return [
`确认将所选 ${count} 条运输计划标记为危化品证书已齐备?`,
'',
...HAZARD_CERT_REQUIREMENT_LINES,
'',
'确认后可安排运输;之后仍可在本页补充上传附件。'
].join('<br>')
}
/** 批量取消证书齐备确认弹窗正文 */
export function buildBatchHazardCertUnmarkConfirmMessage(count: number): string {
const prefix =
count === 1
? '确认取消该计划的证书齐备状态?'
: `确认取消所选 ${count} 条运输计划的证书齐备状态?`
return `${prefix}取消后将无法安排运输。`
}
+9 -2
View File
@@ -377,14 +377,21 @@ export function plPlanListTabToFilters(tab: PlPlanListTab): {
return { planType: plPlanTypeTabToFilter(tab) }
}
/** 危化品证上传页:证书齐备筛选选项 */
export const PL_PLAN_HAZARD_CERT_READY_FILTER_OPTIONS = [
{ label: '未齐备', value: '0' },
{ label: '已齐备', value: '1' }
] as const
export function plPlanCertTabToFilters(tab: PlPlanCertTab): {
planType: string
certUploadStatus?: string
hazardCertReady?: string
} {
if (tab === 'cert_incomplete') {
return { planType: '', certUploadStatus: 'incomplete' }
return { planType: '', certUploadStatus: 'incomplete', hazardCertReady: '0' }
}
return { planType: plPlanTypeTabToFilter(tab) }
return { planType: plPlanTypeTabToFilter(tab), hazardCertReady: '' }
}
export function plPlanTypeFilterToTab(planType?: string): PlPlanTypeTab {
@@ -3,6 +3,7 @@ import { isPlInternalCustomerName } from '@/utils/pl-internal-customer'
export type PlanContractDisplayLine = {
side: string
no: string
contractId?: string
pending?: boolean
}
@@ -26,8 +27,9 @@ export function planContractSideLines(
): PlanContractDisplayLine[] {
const lines: PlanContractDisplayLine[] = []
for (const item of contracts || []) {
const no = item.contractNo || item.contractId
if (no) lines.push({ side, no: String(no) })
const contractId = item.contractId ? String(item.contractId) : undefined
const no = item.contractNo || contractId
if (no) lines.push({ side, no: String(no), contractId })
}
if (lines.length) return lines
if (isPlInternalCustomerName(customerName)) {
@@ -1,5 +1,6 @@
import type { Plan, PlanDelivery, PlanLoad, PlanProduct, PlanStopProduct } from '@/api/pl'
import { formatDate } from '@/utils/format'
import { isPlInternalCustomerName } from '@/utils/pl-internal-customer'
import {
planContractSideLines,
type PlanContractDisplayLine
@@ -29,7 +30,9 @@ export function resolvePlanStopsForList(plan: Plan): {
load: PlanLoad
deliveries: PlanDelivery[]
} {
if (plan.load && (plan.deliveries?.length ?? 0) > 0) {
// 有段次结构时优先用 load/deliveries(含段上 contracts);勿因 deliveries 为空回退 legacy,
// 否则会丢掉 stop.contracts,误用空的 supplierContracts/demanderContracts。
if (plan.load) {
return {
load: plan.load,
deliveries: plan.deliveries || []
@@ -163,6 +166,85 @@ export function getStopCustomerName(row: PlPlanListDisplayRow): string {
return (row.stop.customerName || '').trim() || '—'
}
/**
* 归档列表:入库/出库列展示方向(按本段客户)。
* - 装货:山峡/云栋 → 出库;其他 → 入库
* - 送货:山峡/云栋 → 入库;其他 → 出库
*/
export function resolveArchiveListIoColumn(
kind: PlPlanListDisplayRowKind,
customerName?: string | null
): 'inbound' | 'outbound' {
const internal = isPlInternalCustomerName(customerName)
if (kind === 'load') return internal ? 'outbound' : 'inbound'
return internal ? 'inbound' : 'outbound'
}
/** 段次归档实际数量(装货读 inboundQty,送货读 outboundQty) */
export function getStopArchiveActualQty(product: PlanStopProduct, kind: PlPlanListDisplayRowKind): number | null {
const raw = kind === 'load' ? product.inboundQty : product.outboundQty
if (raw == null || raw === '') return null
const num = Number(raw)
return Number.isFinite(num) ? num : null
}
export function getStopArchiveUnitPrice(
product: PlanStopProduct,
kind: PlPlanListDisplayRowKind
): number | null {
const raw = kind === 'load' ? product.inboundPrice : product.outboundPrice
if (raw == null || raw === '') return null
const num = Number(raw)
return Number.isFinite(num) ? num : null
}
export function getStopArchiveAmount(
product: PlanStopProduct,
kind: PlPlanListDisplayRowKind
): number | null {
const stored = kind === 'load' ? product.inboundAmount : product.outboundAmount
if (stored != null && stored !== '') {
const num = Number(stored)
if (Number.isFinite(num)) return num
}
const qty = getStopArchiveActualQty(product, kind)
const price = getStopArchiveUnitPrice(product, kind)
if (qty == null || price == null) return null
const amount = qty * price
return Number.isFinite(amount) ? amount : null
}
export function sumStopArchiveActualQty(
stopProducts: PlanStopProduct[],
kind: PlPlanListDisplayRowKind
): number {
return stopProducts.reduce((sum, item) => sum + (getStopArchiveActualQty(item, kind) || 0), 0)
}
export function sumStopArchiveIoQty(
stopProducts: PlanStopProduct[],
kind: PlPlanListDisplayRowKind,
customerName: string | null | undefined,
column: 'inbound' | 'outbound'
): number {
if (resolveArchiveListIoColumn(kind, customerName) !== column) return 0
return sumStopArchiveActualQty(stopProducts, kind)
}
export function sumStopArchiveUnitPrice(
stopProducts: PlanStopProduct[],
kind: PlPlanListDisplayRowKind
): number {
return stopProducts.reduce((sum, item) => sum + (getStopArchiveUnitPrice(item, kind) || 0), 0)
}
export function sumStopArchiveAmount(
stopProducts: PlanStopProduct[],
kind: PlPlanListDisplayRowKind
): number {
return stopProducts.reduce((sum, item) => sum + (getStopArchiveAmount(item, kind) || 0), 0)
}
/** 装货行展示供方;送货行留空 */
export function getStopSupplierNameDisplay(row: PlPlanListDisplayRow): string {
if (row.kind !== 'load') return '—'
+13
View File
@@ -32,6 +32,8 @@ export interface PlanStopProductFormRow {
outboundQty?: number
outboundPrice?: number
outboundAmount?: number
/** 本段归档品牌数量 */
brandQtys?: Array<{ brandId: string; brandName?: string; qty: number; unitPrice?: number }>
remark?: string
isExplosive?: number
isToxic?: number
@@ -361,6 +363,17 @@ function mapStopProductFromApi(row: PlanStopProduct): PlanStopProductFormRow {
outboundQty: row.outboundQty != null ? Number(row.outboundQty) : undefined,
outboundPrice: row.outboundPrice != null ? Number(row.outboundPrice) : undefined,
outboundAmount: row.outboundAmount != null ? Number(row.outboundAmount) : undefined,
brandQtys: (row.brandQtys || [])
.filter((item) => Number(item.qty) > 0)
.map((item) => ({
brandId: String(item.brandId || ''),
brandName: item.brandName,
qty: Number(item.qty),
unitPrice:
item.unitPrice != null && Number.isFinite(Number(item.unitPrice))
? Number(item.unitPrice)
: undefined
})),
remark: row.remark
}
}
+91 -6
View File
@@ -360,6 +360,22 @@
</div>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('stopContract')"
label="合同"
min-width="180"
align="left"
header-align="left"
class-name="col-name-wrap pl-plan-stop-col"
label-class-name="col-name-wrap"
>
<template #default="{ row }">
<PlPlanContractCell
:lines="getStopContractLines(row)"
@open="openContractDetail"
/>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('productName')"
label="产品名称"
@@ -436,6 +452,9 @@
>
<template #default="{ row }">
<PlPlanListProductCell
archive-list-mode
:stop-kind="row.kind"
:stop-customer-name="row.stop.customerName"
:stop-products="getStopProducts(row)"
:plan-products="row.plan.products"
field="actualQty"
@@ -452,6 +471,9 @@
>
<template #default="{ row }">
<PlPlanListProductCell
archive-list-mode
:stop-kind="row.kind"
:stop-customer-name="row.stop.customerName"
:stop-products="getStopProducts(row)"
:plan-products="row.plan.products"
field="inboundQty"
@@ -468,12 +490,53 @@
>
<template #default="{ row }">
<PlPlanListProductCell
archive-list-mode
:stop-kind="row.kind"
:stop-customer-name="row.stop.customerName"
:stop-products="getStopProducts(row)"
:plan-products="row.plan.products"
field="outboundQty"
/>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('productUnitPrice')"
label="单价(元)"
min-width="100"
align="right"
header-align="right"
class-name="pl-plan-stop-col"
>
<template #default="{ row }">
<PlPlanListProductCell
archive-list-mode
:stop-kind="row.kind"
:stop-customer-name="row.stop.customerName"
:stop-products="getStopProducts(row)"
:plan-products="row.plan.products"
field="unitPrice"
/>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('productAmount')"
label="金额(元)"
min-width="100"
align="right"
header-align="right"
class-name="pl-plan-stop-col"
>
<template #default="{ row }">
<PlPlanListProductCell
archive-list-mode
:stop-kind="row.kind"
:stop-customer-name="row.stop.customerName"
:stop-products="getStopProducts(row)"
:plan-products="row.plan.products"
field="amount"
/>
</template>
</el-table-column>
</ListTable>
<ListColumnStatsRow
ref="archiveStatsRowRef"
@@ -521,6 +584,8 @@
</template>
</PlPlanViewDialog>
<ContractDetailDialog ref="contractDetailRef" hide-product-price />
<PlPlanArchiveDialog
v-model:visible="archiveDialogVisible"
:plan-id="archivePlanId"
@@ -535,6 +600,7 @@ import ListToolbarEndActions from '@/components/business/list-toolbar-end-action
import ListExportDropdown from '@/components/business/list-export-dropdown/index.vue'
import ListColumnStatsRow from '@/components/business/list-column-stats-row/index.vue'
import type { StatsRowColumn } from '@/utils/list-column-stats'
import { useContractDetailDialog } from '@/composables/useContractDetailDialog'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import { usePlPlanMergeListTable } from '@/composables/usePlPlanMergeListTable'
import {
@@ -550,6 +616,7 @@ import {
enrichPlanProductsHazardFromCrm
} from '@/composables/usePlToxicProductCertCheck'
import { pickBxData } from '@/utils/http/bx'
import PlPlanContractCell from '@/components/business/pl-plan-contract-cell/index.vue'
import PlPlanArchiveDialog from '@/components/business/pl-plan-archive-dialog/index.vue'
import PlPlanDocumentAttachmentCell from '@/components/business/pl-plan-document-attachment-cell/index.vue'
import PlPlanListCertCell from '@/components/business/pl-plan-list-cert-cell/index.vue'
@@ -574,11 +641,14 @@ import { syncViewRowFromFlatList } from '@/utils/view-dialog-refresh'
import {
getPlPlanListRowClassName,
getStopAddress,
getStopContractLines,
getStopCustomerName,
getStopKindLabel,
getStopProducts,
getStopTime,
sumStopActualQty,
sumStopArchiveActualQty,
sumStopArchiveAmount,
sumStopArchiveIoQty,
sumStopProductsField,
type PlPlanListDisplayRow
} from '@/utils/pl-plan-list-display-rows'
@@ -587,6 +657,7 @@ import { refreshMenuBadges } from '@/utils/menu-badge-registry'
import { useMenuStore } from '@/store/modules/menu'
const menuStore = useMenuStore()
const { ContractDetailDialog, contractDetailRef, openContractDetail } = useContractDetailDialog()
/** 计划区可合并列(顺序须与表格左侧列一致,不含勾选/序号/计划编号) */
const PLAN_MERGE_COLUMN_PROPS = [
@@ -627,13 +698,16 @@ const listColumnDefs = [
{ prop: 'stopKind', label: '段次' },
{ prop: 'stopCustomer', label: '客户' },
{ prop: 'stopAddressTime', label: '地址 / 时间' },
{ prop: 'stopContract', label: '合同' },
{ prop: 'productName', label: '产品名称' },
{ prop: 'productSpec', label: '型号/规格' },
{ prop: 'productUnit', label: '单位' },
{ prop: 'productPlanQty', label: '计划数量' },
{ prop: 'productActualQty', label: '实际数量' },
{ prop: 'productInboundQty', label: '入库数量' },
{ prop: 'productOutboundQty', label: '出库数量' }
{ prop: 'productOutboundQty', label: '出库数量' },
{ prop: 'productUnitPrice', label: '单价(元)' },
{ prop: 'productAmount', label: '金额(元)' }
]
const {
@@ -642,7 +716,7 @@ const {
isIndeterminate,
isColumnVisible,
resetToDefault
} = usePersistedListColumns('pl-plan-archive-list-columns-v3', listColumnDefs)
} = usePersistedListColumns('pl-plan-archive-list-columns-v5', listColumnDefs)
const selectedRows = ref<Plan[]>([])
@@ -701,10 +775,21 @@ function getArchiveStatCellValue(row: PlPlanListDisplayRow, prop: string): unkno
if (prop === 'stopAddressTime') {
return `${getStopAddress(row)} ${getStopTime(row)}`.trim()
}
if (prop === 'stopContract') {
return getStopContractLines(row)
.map((l) => `${l.side}${l.no}`)
.join(' ')
}
if (prop === 'productPlanQty') return sumStopProductsField(stopProducts, 'planQty')
if (prop === 'productActualQty') return sumStopActualQty(plan, stopProducts)
if (prop === 'productInboundQty') return sumStopProductsField(stopProducts, 'inboundQty')
if (prop === 'productOutboundQty') return sumStopProductsField(stopProducts, 'outboundQty')
if (prop === 'productActualQty') return sumStopArchiveActualQty(stopProducts, row.kind)
if (prop === 'productInboundQty') {
return sumStopArchiveIoQty(stopProducts, row.kind, row.stop.customerName, 'inbound')
}
if (prop === 'productOutboundQty') {
return sumStopArchiveIoQty(stopProducts, row.kind, row.stop.customerName, 'outbound')
}
if (prop === 'productUnitPrice') return ''
if (prop === 'productAmount') return sumStopArchiveAmount(stopProducts, row.kind)
if (prop === 'productName') {
return stopProducts
.map((p) => p.productName || '')
+485 -372
View File
@@ -31,11 +31,7 @@
</el-select>
</el-form-item>
<el-form-item label="客户名称">
<el-input
v-model="searchForm.customerName"
placeholder="搜索客户名称"
clearable
/>
<el-input v-model="searchForm.customerName" placeholder="搜索客户名称" clearable />
</el-form-item>
<el-form-item label="计划日期">
<el-date-picker
@@ -48,6 +44,16 @@
style="width: 100%"
/>
</el-form-item>
<el-form-item label="证书齐备">
<el-select v-model="searchForm.hazardCertReady" placeholder="全部" clearable>
<el-option
v-for="opt in PL_PLAN_HAZARD_CERT_READY_FILTER_OPTIONS"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
</div>
<div class="filter-actions">
<div class="filter-actions-start" />
@@ -78,25 +84,9 @@
@clear="handleQuickSearchClear"
/>
<el-button
:class="
selectedPlan &&
canManageCertReady(selectedPlan) &&
!isHazardCertReady(selectedPlan)
? 'btn-main'
: 'btn-ghost'
"
:type="
selectedPlan &&
canManageCertReady(selectedPlan) &&
!isHazardCertReady(selectedPlan)
? 'primary'
: undefined
"
:disabled="
!selectedPlan ||
!canManageCertReady(selectedPlan) ||
isHazardCertReady(selectedPlan)
"
:class="canToolbarMarkCertReady ? 'btn-main' : 'btn-ghost'"
:type="canToolbarMarkCertReady ? 'primary' : undefined"
:disabled="!canToolbarMarkCertReady"
:loading="certReadyLoading"
@click="handleToolbarMarkCertReady"
>
@@ -104,16 +94,12 @@
</el-button>
<el-button
:class="
selectedPlan &&
canManageCertReady(selectedPlan) &&
!isHazardCertReady(selectedPlan)
selectedPlan && canManageCertReady(selectedPlan) && !isHazardCertReady(selectedPlan)
? 'btn-ghost'
: 'btn-main'
"
:type="
selectedPlan &&
canManageCertReady(selectedPlan) &&
!isHazardCertReady(selectedPlan)
selectedPlan && canManageCertReady(selectedPlan) && !isHazardCertReady(selectedPlan)
? undefined
: 'primary'
"
@@ -124,11 +110,7 @@
</el-button>
<el-button
class="btn-ghost"
:disabled="
!selectedPlan ||
!canManageCertReady(selectedPlan) ||
!isHazardCertReady(selectedPlan)
"
:disabled="!canToolbarUnmarkCertReady"
:loading="certReadyLoading"
@click="handleToolbarUnmarkCertReady"
>
@@ -335,6 +317,22 @@
</div>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('stopContract')"
label="合同"
min-width="180"
align="left"
header-align="left"
class-name="col-name-wrap pl-plan-stop-col"
label-class-name="col-name-wrap"
>
<template #default="{ row }">
<PlPlanContractCell
:lines="getStopContractLines(row)"
@open="openContractDetail"
/>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('productName')"
label="产品名称"
@@ -411,6 +409,7 @@
>
<template #default="{ row }">
<PlPlanListProductCell
:stop-kind="row.kind"
:stop-products="getStopProducts(row)"
:plan-products="row.plan.products"
field="actualQty"
@@ -503,6 +502,8 @@
</template>
</PlPlanViewDialog>
<ContractDetailDialog ref="contractDetailRef" hide-product-price />
<PlPlanCertUploadDialog
v-model:visible="certDialogVisible"
:plan-id="certPlanId"
@@ -512,180 +513,134 @@
</template>
<script setup lang="ts">
import {
CircleCheck,
CircleClose,
RefreshLeft,
Search,
Upload
} from '@element-plus/icons-vue'
import { planApi, type Plan } from '@/api/pl'
import PlPlanCertUploadDialog from '@/components/business/pl-plan-cert-upload-dialog/index.vue'
import PlPlanListCertCell from '@/components/business/pl-plan-list-cert-cell/index.vue'
import PlPlanListProductCell from '@/components/business/pl-plan-list-product-cell/index.vue'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListExportDropdown from '@/components/business/list-export-dropdown/index.vue'
import {
buildHazardCertReadyConfirmMessage,
isHazardCertReady,
planHasHazardProducts
} from '@/composables/usePlToxicProductCertCheck'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import { usePlPlanMergeListTable } from '@/composables/usePlPlanMergeListTable'
import PlPlanViewDialog from '@/components/business/pl-plan-view-dialog/index.vue'
import PlPlanTypeTabs from '@/components/business/pl-plan-type-tabs/index.vue'
import ListToolbarDateShortcuts from '@/components/core/list-toolbar-date-shortcuts/index.vue'
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
import { useListDateShortcut } from '@/composables/useListDateShortcut'
import { useListMediaActive } from '@/composables/useListMediaActive'
import {
formatPlVehiclePlateDisplay,
getPlPlanFollowStatusLabel,
getPlPlanFollowStatusTagType,
normalizePlPersonnelSummaryDisplay,
PL_PLAN_CERT_TABS,
PL_PLAN_FOLLOW_STATUS_OPTIONS,
plPlanCertTabToFilters,
type PlPlanCertTab
} from '@/utils/pl-display'
import { syncViewRowFromFlatList } from '@/utils/view-dialog-refresh'
import {
getPlPlanListRowClassName,
getStopAddress,
getStopCustomerName,
getStopKindLabel,
getStopProducts,
getStopTime
} from '@/utils/pl-plan-list-display-rows'
import { isPlInternalCustomerName } from '@/utils/pl-internal-customer'
import { refreshMenuBadges } from '@/utils/menu-badge-registry'
import { useMenuStore } from '@/store/modules/menu'
import { CircleCheck, CircleClose, RefreshLeft, Search, Upload } from '@element-plus/icons-vue'
import { planApi, type Plan } from '@/api/pl'
import PlPlanCertUploadDialog from '@/components/business/pl-plan-cert-upload-dialog/index.vue'
import PlPlanContractCell from '@/components/business/pl-plan-contract-cell/index.vue'
import PlPlanListCertCell from '@/components/business/pl-plan-list-cert-cell/index.vue'
import PlPlanListProductCell from '@/components/business/pl-plan-list-product-cell/index.vue'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListExportDropdown from '@/components/business/list-export-dropdown/index.vue'
import {
buildBatchHazardCertReadyConfirmMessage,
buildBatchHazardCertUnmarkConfirmMessage,
buildHazardCertReadyConfirmMessage,
isHazardCertReady,
planHasHazardProducts
} from '@/composables/usePlToxicProductCertCheck'
import { useContractDetailDialog } from '@/composables/useContractDetailDialog'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import { usePlPlanMergeListTable } from '@/composables/usePlPlanMergeListTable'
import PlPlanViewDialog from '@/components/business/pl-plan-view-dialog/index.vue'
import PlPlanTypeTabs from '@/components/business/pl-plan-type-tabs/index.vue'
import ListToolbarDateShortcuts from '@/components/core/list-toolbar-date-shortcuts/index.vue'
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
import { useListDateShortcut } from '@/composables/useListDateShortcut'
import { useListMediaActive } from '@/composables/useListMediaActive'
import {
formatPlVehiclePlateDisplay,
getPlPlanFollowStatusLabel,
getPlPlanFollowStatusTagType,
normalizePlPersonnelSummaryDisplay,
PL_PLAN_CERT_TABS,
PL_PLAN_FOLLOW_STATUS_OPTIONS,
PL_PLAN_HAZARD_CERT_READY_FILTER_OPTIONS,
plPlanCertTabToFilters,
type PlPlanCertTab
} from '@/utils/pl-display'
import { syncViewRowFromFlatList } from '@/utils/view-dialog-refresh'
import {
getPlPlanListRowClassName,
getStopAddress,
getStopContractLines,
getStopCustomerName,
getStopKindLabel,
getStopProducts,
getStopTime
} from '@/utils/pl-plan-list-display-rows'
import { isPlInternalCustomerName } from '@/utils/pl-internal-customer'
import { refreshMenuBadges } from '@/utils/menu-badge-registry'
import { useMenuStore } from '@/store/modules/menu'
const menuStore = useMenuStore()
const menuStore = useMenuStore()
const { ContractDetailDialog, contractDetailRef, openContractDetail } = useContractDetailDialog()
const PLAN_MERGE_COLUMN_PROPS = [
'planDate',
'planType',
'followStatus',
'hazardCertReady',
'planAttachments',
'licensePlate',
'personnelSummary',
'followUserName'
] as const
const PLAN_MERGE_COLUMN_PROPS = [
'planDate',
'planType',
'followStatus',
'hazardCertReady',
'planAttachments',
'licensePlate',
'personnelSummary',
'followUserName'
] as const
const loading = ref(false)
const { mediaActive } = useListMediaActive()
const certReadyLoading = ref(false)
const viewDialogRef = ref<InstanceType<typeof PlPlanViewDialog>>()
const viewVisible = ref(false)
const viewPlanId = ref('')
const certDialogVisible = ref(false)
const certPlanId = ref('')
const planList = ref<Plan[]>([])
const loading = ref(false)
const { mediaActive } = useListMediaActive()
const certReadyLoading = ref(false)
const viewDialogRef = ref<InstanceType<typeof PlPlanViewDialog>>()
const viewVisible = ref(false)
const viewPlanId = ref('')
const certDialogVisible = ref(false)
const certPlanId = ref('')
const planList = ref<Plan[]>([])
const listColumnDefs = [
{ prop: 'planDate', label: '计划日期' },
{ prop: 'planType', label: '单据类型' },
{ prop: 'followStatus', label: '跟进状态' },
{ prop: 'hazardCertReady', label: '证书齐备' },
{ prop: 'planAttachments', label: '计划附件' },
{ prop: 'licensePlate', label: '车牌号' },
{ prop: 'personnelSummary', label: '人员' },
{ prop: 'followUserName', label: '跟进人', defaultVisible: false },
{ prop: 'stopKind', label: '段次' },
{ prop: 'stopCustomer', label: '客户' },
{ prop: 'stopAddressTime', label: '地址 / 时间' },
{ prop: 'productName', label: '产品名称' },
{ prop: 'productSpec', label: '型号/规格' },
{ prop: 'productUnit', label: '单位' },
{ prop: 'productPlanQty', label: '计划数量' },
{ prop: 'productActualQty', label: '实际数量' },
{ prop: 'productInboundQty', label: '入库数量' },
{ prop: 'productOutboundQty', label: '出库数量' }
]
const listColumnDefs = [
{ prop: 'planDate', label: '计划日期' },
{ prop: 'planType', label: '单据类型' },
{ prop: 'followStatus', label: '跟进状态' },
{ prop: 'hazardCertReady', label: '证书齐备' },
{ prop: 'planAttachments', label: '计划附件' },
{ prop: 'licensePlate', label: '车牌号' },
{ prop: 'personnelSummary', label: '人员' },
{ prop: 'followUserName', label: '跟进人', defaultVisible: false },
{ prop: 'stopKind', label: '段次' },
{ prop: 'stopCustomer', label: '客户' },
{ prop: 'stopAddressTime', label: '地址 / 时间' },
{ prop: 'stopContract', label: '合同' },
{ prop: 'productName', label: '产品名称' },
{ prop: 'productSpec', label: '型号/规格' },
{ prop: 'productUnit', label: '单位' },
{ prop: 'productPlanQty', label: '计划数量' },
{ prop: 'productActualQty', label: '实际数量' },
{ prop: 'productInboundQty', label: '入库数量' },
{ prop: 'productOutboundQty', label: '出库数量' }
]
const {
visibleColumns,
checkAll,
isIndeterminate,
isColumnVisible,
resetToDefault
} = usePersistedListColumns('pl-plan-cert-list-columns-v3', listColumnDefs)
const { visibleColumns, checkAll, isIndeterminate, isColumnVisible, resetToDefault } =
usePersistedListColumns('pl-plan-cert-list-columns-v4', listColumnDefs)
const selectedRows = ref<Plan[]>([])
const selectedRows = ref<Plan[]>([])
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0
})
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0
})
const visiblePlanMergeProps = computed(() =>
PLAN_MERGE_COLUMN_PROPS.filter((prop) => isColumnVisible(prop))
)
const visiblePlanMergeProps = computed(() =>
PLAN_MERGE_COLUMN_PROPS.filter((prop) => isColumnVisible(prop))
)
const {
displayRows,
planListSpanMethod,
getDisplayRowIndex,
isPlanFirstRowSelectable,
handleDisplaySelectionChange,
handleDisplayRowDblClick
} = usePlPlanMergeListTable({
planList,
visiblePlanMergeProps,
selectedRows,
pagination,
onView: (plan) => handleView(plan)
})
function getPlanCertExportFilterParams() {
const keyword = quickSearch.value.trim()
return {
q: keyword || undefined,
planNo: keyword ? undefined : searchForm.planNo || undefined,
planType: searchForm.planType || undefined,
followStatus: searchForm.followStatus || undefined,
customerName: keyword ? undefined : searchForm.customerName || undefined,
planDateFrom: dateRange.value?.[0] || undefined,
planDateTo: dateRange.value?.[1] || undefined,
hasHazardProduct: 1,
certUploadStatus: searchForm.certUploadStatus || undefined
}
}
const planCertExportColumns = computed(() => {
const skip = new Set(['stopKind', 'stopCustomer', 'stopAddressTime'])
const cols = visibleColumns.value.filter((col) => !skip.has(col))
if (!cols.includes('planNo')) cols.unshift('planNo')
return cols
})
const viewingPlan = ref<Plan | null>(null)
const selectedPlan = computed(() => (selectedRows.value.length === 1 ? selectedRows.value[0] : null))
const dateRange = ref<string[]>([])
const quickSearch = ref('')
const activePlanTypeTab = ref<PlPlanCertTab>('all')
const searchForm = reactive({
planNo: '',
planType: '',
followStatus: '',
customerName: '',
certUploadStatus: '' as string
})
function getPlanTypeLabel(type: string) {
const map: Record<string, string> = { direct: '直销单', inbound: '入库单', outbound: '出库单' }
return map[type] || type
}
async function fetchData() {
loading.value = true
try {
const {
displayRows,
planListSpanMethod,
getDisplayRowIndex,
isPlanFirstRowSelectable,
handleDisplaySelectionChange,
handleDisplayRowDblClick
} = usePlPlanMergeListTable({
planList,
visiblePlanMergeProps,
selectedRows,
pagination,
onView: (plan) => handleView(plan)
})
function getPlanCertExportFilterParams() {
const keyword = quickSearch.value.trim()
const res = await planApi.list({
return {
q: keyword || undefined,
planNo: keyword ? undefined : searchForm.planNo || undefined,
planType: searchForm.planType || undefined,
@@ -695,194 +650,352 @@ async function fetchData() {
planDateTo: dateRange.value?.[1] || undefined,
hasHazardProduct: 1,
certUploadStatus: searchForm.certUploadStatus || undefined,
page: pagination.page,
pageSize: pagination.pageSize
})
const data = res.data ?? res
planList.value = data.list || []
pagination.total = data.total || 0
} finally {
loading.value = false
hazardCertReady:
searchForm.hazardCertReady === '' ? undefined : Number(searchForm.hazardCertReady)
}
}
}
function handleSearch() {
pagination.page = 1
fetchData()
}
function handlePlanTypeTabChange(tab: PlPlanCertTab | string) {
const filters = plPlanCertTabToFilters(tab as PlPlanCertTab)
searchForm.planType = filters.planType
searchForm.certUploadStatus = filters.certUploadStatus || ''
pagination.page = 1
fetchData()
}
function handleReset() {
activePlanTypeTab.value = 'all'
searchForm.planNo = ''
searchForm.planType = ''
searchForm.followStatus = ''
searchForm.customerName = ''
searchForm.certUploadStatus = ''
quickSearch.value = ''
resetDateShortcut()
pagination.page = 1
fetchData()
}
function handleQuickSearch() {
searchForm.planNo = ''
searchForm.customerName = ''
pagination.page = 1
fetchData()
}
function handleQuickSearchClear() {
quickSearch.value = ''
handleQuickSearch()
}
const { activeDateShortcut, handleDateShortcut, resetDateShortcut } = useListDateShortcut(
dateRange,
handleSearch
)
function handlePageChange() {
fetchData()
}
function handleSizeChange() {
pagination.page = 1
fetchData()
}
function canUploadCert(row: Plan) {
return row.archiveStatus !== 'archived' && row.followStatus !== 'cancelled'
}
function canManageCertReady(row: Plan) {
return canUploadCert(row) && planHasHazardProducts(row.products)
}
function getHazardCertReadyLabel(row: Plan) {
if (!planHasHazardProducts(row.products)) return '无需'
return isHazardCertReady(row) ? '已齐备' : '待确认'
}
function getHazardCertReadyTagType(row: Plan) {
if (!planHasHazardProducts(row.products)) return 'info'
return isHazardCertReady(row) ? 'success' : 'warning'
}
async function refreshSidebarBadges() {
await refreshMenuBadges(menuStore.menuList)
}
async function handleMarkCertReady(row: Plan) {
await ElMessageBox.confirm(buildHazardCertReadyConfirmMessage(), '证书齐备确认', {
type: 'warning',
confirmButtonText: '确认齐备',
cancelButtonText: '取消',
dangerouslyUseHTMLString: true
const planCertExportColumns = computed(() => {
const skip = new Set(['stopKind', 'stopCustomer', 'stopAddressTime', 'stopContract'])
const cols = visibleColumns.value.filter((col) => !skip.has(col))
if (!cols.includes('planNo')) cols.unshift('planNo')
return cols
})
certReadyLoading.value = true
try {
await planApi.setHazardCertReady(row.id, true)
ElMessage.success('已标记证书齐备')
await fetchData()
await refreshViewIfOpen()
await refreshSidebarBadges()
} finally {
certReadyLoading.value = false
}
}
async function handleUnmarkCertReady(row: Plan) {
await ElMessageBox.confirm('确认取消该计划的证书齐备状态?取消后将无法安排运输。', '取消齐备', {
type: 'warning',
confirmButtonText: '确认取消',
cancelButtonText: '返回'
const viewingPlan = ref<Plan | null>(null)
const selectedPlan = computed(() =>
selectedRows.value.length === 1 ? selectedRows.value[0] : null
)
const dateRange = ref<string[]>([])
const quickSearch = ref('')
const activePlanTypeTab = ref<PlPlanCertTab>('all')
const searchForm = reactive({
planNo: '',
planType: '',
followStatus: '',
customerName: '',
certUploadStatus: '' as string,
hazardCertReady: '' as string
})
certReadyLoading.value = true
try {
await planApi.setHazardCertReady(row.id, false)
ElMessage.success('已取消证书齐备')
await fetchData()
await refreshViewIfOpen()
await refreshSidebarBadges()
} finally {
certReadyLoading.value = false
function getPlanTypeLabel(type: string) {
const map: Record<string, string> = { direct: '直销单', inbound: '入库单', outbound: '出库单' }
return map[type] || type
}
}
function handleToolbarMarkCertReady() {
if (selectedRows.value.length !== 1) return
handleMarkCertReady(selectedRows.value[0])
}
async function fetchData() {
loading.value = true
try {
const keyword = quickSearch.value.trim()
const res = await planApi.list({
q: keyword || undefined,
planNo: keyword ? undefined : searchForm.planNo || undefined,
planType: searchForm.planType || undefined,
followStatus: searchForm.followStatus || undefined,
customerName: keyword ? undefined : searchForm.customerName || undefined,
planDateFrom: dateRange.value?.[0] || undefined,
planDateTo: dateRange.value?.[1] || undefined,
hasHazardProduct: 1,
certUploadStatus: searchForm.certUploadStatus || undefined,
hazardCertReady:
searchForm.hazardCertReady === '' ? undefined : Number(searchForm.hazardCertReady),
page: pagination.page,
pageSize: pagination.pageSize
})
const data = res.data ?? res
planList.value = data.list || []
pagination.total = data.total || 0
} finally {
loading.value = false
}
}
function handleToolbarUnmarkCertReady() {
if (selectedRows.value.length !== 1) return
handleUnmarkCertReady(selectedRows.value[0])
}
function handleSearch() {
pagination.page = 1
fetchData()
}
function syncViewingPlanFromList() {
syncViewRowFromFlatList(viewingPlan, planList.value)
}
function handlePlanTypeTabChange(tab: PlPlanCertTab | string) {
const filters = plPlanCertTabToFilters(tab as PlPlanCertTab)
searchForm.planType = filters.planType
searchForm.certUploadStatus = filters.certUploadStatus || ''
searchForm.hazardCertReady = filters.hazardCertReady ?? ''
pagination.page = 1
fetchData()
}
async function refreshViewIfOpen() {
if (!viewVisible.value || !viewPlanId.value) return
syncViewingPlanFromList()
await viewDialogRef.value?.reload()
}
async function handleCertUploadSuccess() {
await fetchData()
await refreshViewIfOpen()
await refreshSidebarBadges()
}
function handleView(row: Plan) {
viewingPlan.value = row
viewPlanId.value = row.id
viewVisible.value = true
}
function handleUploadCert(row: Plan) {
certPlanId.value = row.id
certDialogVisible.value = true
}
function handleToolbarUploadCert() {
if (selectedRows.value.length !== 1) return
handleUploadCert(selectedRows.value[0])
}
useDbInstantSearch(
() => ({
planNo: searchForm.planNo,
planType: searchForm.planType,
followStatus: searchForm.followStatus,
customerName: searchForm.customerName,
certUploadStatus: searchForm.certUploadStatus,
dateRange: dateRange.value
}),
() => {
function handleReset() {
activePlanTypeTab.value = 'all'
searchForm.planNo = ''
searchForm.planType = ''
searchForm.followStatus = ''
searchForm.customerName = ''
searchForm.certUploadStatus = ''
searchForm.hazardCertReady = ''
quickSearch.value = ''
handleSearch()
resetDateShortcut()
pagination.page = 1
fetchData()
}
)
onMounted(() => {
fetchData()
})
function handleQuickSearch() {
searchForm.planNo = ''
searchForm.customerName = ''
pagination.page = 1
fetchData()
}
function handleQuickSearchClear() {
quickSearch.value = ''
handleQuickSearch()
}
const { activeDateShortcut, handleDateShortcut, resetDateShortcut } = useListDateShortcut(
dateRange,
handleSearch
)
function handlePageChange() {
fetchData()
}
function handleSizeChange() {
pagination.page = 1
fetchData()
}
function canUploadCert(row: Plan) {
return row.archiveStatus !== 'archived' && row.followStatus !== 'cancelled'
}
function canManageCertReady(row: Plan) {
return canUploadCert(row) && planHasHazardProducts(row.products)
}
const certReadyEligibleRows = computed(() =>
selectedRows.value.filter((row) => canManageCertReady(row) && !isHazardCertReady(row))
)
const canToolbarMarkCertReady = computed(() => certReadyEligibleRows.value.length > 0)
const certUnmarkEligibleRows = computed(() =>
selectedRows.value.filter((row) => canManageCertReady(row) && isHazardCertReady(row))
)
const canToolbarUnmarkCertReady = computed(() => certUnmarkEligibleRows.value.length > 0)
function getHazardCertReadyLabel(row: Plan) {
if (!planHasHazardProducts(row.products)) return '无需'
return isHazardCertReady(row) ? '已齐备' : '待确认'
}
function getHazardCertReadyTagType(row: Plan) {
if (!planHasHazardProducts(row.products)) return 'info'
return isHazardCertReady(row) ? 'success' : 'warning'
}
async function refreshSidebarBadges() {
await refreshMenuBadges(menuStore.menuList)
}
async function handleMarkCertReady(row: Plan) {
await ElMessageBox.confirm(buildHazardCertReadyConfirmMessage(), '证书齐备确认', {
type: 'warning',
confirmButtonText: '确认齐备',
cancelButtonText: '取消',
dangerouslyUseHTMLString: true
})
certReadyLoading.value = true
try {
await planApi.setHazardCertReady(row.id, true)
ElMessage.success('已标记证书齐备')
await fetchData()
await refreshViewIfOpen()
await refreshSidebarBadges()
} finally {
certReadyLoading.value = false
}
}
async function handleUnmarkCertReady(row: Plan) {
await ElMessageBox.confirm('确认取消该计划的证书齐备状态?取消后将无法安排运输。', '取消齐备', {
type: 'warning',
confirmButtonText: '确认取消',
cancelButtonText: '返回'
})
certReadyLoading.value = true
try {
await planApi.setHazardCertReady(row.id, false)
ElMessage.success('已取消证书齐备')
await fetchData()
await refreshViewIfOpen()
await refreshSidebarBadges()
} finally {
certReadyLoading.value = false
}
}
function handleToolbarMarkCertReady() {
void handleBatchMarkCertReady()
}
async function handleBatchMarkCertReady() {
if (!selectedRows.value.length) {
ElMessage.warning('请先勾选运输计划')
return
}
const targets = certReadyEligibleRows.value
const skipped = selectedRows.value.length - targets.length
if (!targets.length) {
ElMessage.warning('所选计划均不可标记证书齐备(已齐备、已归档/作废或无危化品)')
return
}
const confirmMessage =
targets.length === 1
? buildHazardCertReadyConfirmMessage()
: buildBatchHazardCertReadyConfirmMessage(targets.length)
await ElMessageBox.confirm(confirmMessage, '证书齐备确认', {
type: 'warning',
confirmButtonText: '确认齐备',
cancelButtonText: '取消',
dangerouslyUseHTMLString: true
})
certReadyLoading.value = true
let success = 0
let failed = 0
try {
for (const row of targets) {
try {
await planApi.setHazardCertReady(row.id, true)
success += 1
} catch {
failed += 1
}
}
if (failed === 0) {
const skippedHint = skipped > 0 ? `,已跳过 ${skipped} 条不可操作项` : ''
ElMessage.success(`已标记 ${success} 条计划证书齐备${skippedHint}`)
} else {
ElMessage.warning(`成功 ${success} 条,失败 ${failed} 条`)
}
await fetchData()
await refreshViewIfOpen()
await refreshSidebarBadges()
} finally {
certReadyLoading.value = false
}
}
function handleToolbarUnmarkCertReady() {
void handleBatchUnmarkCertReady()
}
async function handleBatchUnmarkCertReady() {
if (!selectedRows.value.length) {
ElMessage.warning('请先勾选运输计划')
return
}
const targets = certUnmarkEligibleRows.value
const skipped = selectedRows.value.length - targets.length
if (!targets.length) {
ElMessage.warning('所选计划均不可取消证书齐备(未齐备、已归档/作废或无危化品)')
return
}
await ElMessageBox.confirm(
buildBatchHazardCertUnmarkConfirmMessage(targets.length),
'取消齐备',
{
type: 'warning',
confirmButtonText: '确认取消',
cancelButtonText: '返回'
}
)
certReadyLoading.value = true
let success = 0
let failed = 0
try {
for (const row of targets) {
try {
await planApi.setHazardCertReady(row.id, false)
success += 1
} catch {
failed += 1
}
}
if (failed === 0) {
const skippedHint = skipped > 0 ? `,已跳过 ${skipped} 条不可操作项` : ''
ElMessage.success(`已取消 ${success} 条计划证书齐备${skippedHint}`)
} else {
ElMessage.warning(`成功 ${success} 条,失败 ${failed} 条`)
}
await fetchData()
await refreshViewIfOpen()
await refreshSidebarBadges()
} finally {
certReadyLoading.value = false
}
}
function syncViewingPlanFromList() {
syncViewRowFromFlatList(viewingPlan, planList.value)
}
async function refreshViewIfOpen() {
if (!viewVisible.value || !viewPlanId.value) return
syncViewingPlanFromList()
await viewDialogRef.value?.reload()
}
async function handleCertUploadSuccess() {
await fetchData()
await refreshViewIfOpen()
await refreshSidebarBadges()
}
function handleView(row: Plan) {
viewingPlan.value = row
viewPlanId.value = row.id
viewVisible.value = true
}
function handleUploadCert(row: Plan) {
certPlanId.value = row.id
certDialogVisible.value = true
}
function handleToolbarUploadCert() {
if (selectedRows.value.length !== 1) return
handleUploadCert(selectedRows.value[0])
}
useDbInstantSearch(
() => ({
planNo: searchForm.planNo,
planType: searchForm.planType,
followStatus: searchForm.followStatus,
customerName: searchForm.customerName,
certUploadStatus: searchForm.certUploadStatus,
hazardCertReady: searchForm.hazardCertReady,
dateRange: dateRange.value
}),
() => {
quickSearch.value = ''
handleSearch()
}
)
onMounted(() => {
fetchData()
})
</script>
<style scoped lang="scss">
@use '@styles/business/pl-plan-merge-list-table.scss';
@use '@styles/business/pl-plan-merge-list-table.scss';
.pl-plan-list-toolbar-row {
flex-wrap: wrap;
}
.pl-plan-list-toolbar-row {
flex-wrap: wrap;
}
</style>
+276 -322
View File
@@ -1,5 +1,5 @@
<!-- eslint-disable vue/no-v-model-argument -->
<!-- 运输计划合同完善:仅缺合同计划,行内展开分侧保存 -->
<!-- 运输计划合同完善:仅缺合同计划,按段次客户弹窗补充合同 -->
<template>
<div class="page-container">
<section class="panel">
@@ -17,15 +17,15 @@
<el-form-item label="缺口类型">
<el-select v-model="searchForm.gapType" placeholder="全部缺口" clearable>
<el-option label="全部缺口" value="" />
<el-option label="仅缺供方合同" value="supplier" />
<el-option label="仅缺需方合同" value="demander" />
<el-option label="两侧均缺" value="both" />
<el-option label="装货段缺合同" value="supplier" />
<el-option label="送货段缺合同" value="demander" />
<el-option label="装货与送货均缺" value="both" />
</el-select>
</el-form-item>
<el-form-item label="供方 / 需方">
<el-form-item label="客户名称">
<el-input
v-model="searchForm.customerKeyword"
placeholder="客户名称"
placeholder="段次客户名称"
clearable
@keyup.enter="handleSearch"
/>
@@ -66,7 +66,7 @@
/>
<ListQuickSearchInput
v-model="quickSearch"
placeholder="计划编号/供方/需方"
placeholder="计划编号/客户名称"
@search="handleQuickSearch"
@clear="handleQuickSearchClear"
/>
@@ -87,107 +87,14 @@
<div class="list-body list-wrap">
<el-table
ref="tableRef"
class="list-table pl-plan-merge-list-table"
:data="displayRows"
v-loading="loading"
row-key="rowKey"
:span-method="planListSpanMethod"
:row-class-name="getPlPlanListRowClassName"
:expand-row-keys="expandedRowKeys"
@expand-change="handleExpandChange"
@row-dblclick="handleDisplayRowDblClick"
>
<el-table-column type="expand" class-name="pl-contract-expand-col" width="1">
<template #default="{ row }">
<div class="pl-contract-expand" v-if="expandDraft[row.plan.id]">
<div class="pl-contract-expand__grid">
<div
class="pl-contract-side"
:class="sideBoxClass(asGapItem(row), 'supplier')"
>
<div class="pl-contract-side__head">
<h4>供方合同</h4>
<el-tag
:type="sideTagType(asGapItem(row), 'supplier')"
size="small"
:effect="asGapItem(row).supplierContractExempt ? 'plain' : 'dark'"
>
{{ sideTagLabel(asGapItem(row), 'supplier') }}
</el-tag>
</div>
<p class="pl-contract-side__customer">
{{ asGapItem(row).supplierName || '—' }}
</p>
<template v-if="asGapItem(row).supplierContractExempt">
<p class="pl-contract-side__exempt">内置公司,合同可免</p>
</template>
<template v-else>
<PlPlanContractMultiSelect
v-model="expandDraft[row.plan.id].supplierContracts"
:customer-id="asGapItem(row).supplierId"
:disabled="!asGapItem(row).supplierId"
:required="false"
/>
<div class="pl-contract-side__actions">
<el-button
class="btn-main"
type="primary"
size="small"
:loading="savingKey === `${row.plan.id}:supplier`"
@click="handleSaveSide(asGapItem(row), 'supplier')"
>
<el-icon><Check /></el-icon>保存供方合同
</el-button>
</div>
</template>
</div>
<div
class="pl-contract-side"
:class="sideBoxClass(asGapItem(row), 'demander')"
>
<div class="pl-contract-side__head">
<h4>需方合同</h4>
<el-tag
:type="sideTagType(asGapItem(row), 'demander')"
size="small"
:effect="asGapItem(row).demanderContractExempt ? 'plain' : 'dark'"
>
{{ sideTagLabel(asGapItem(row), 'demander') }}
</el-tag>
</div>
<p class="pl-contract-side__customer">
{{ asGapItem(row).customerName || '—' }}
</p>
<template v-if="asGapItem(row).demanderContractExempt">
<p class="pl-contract-side__exempt">内置公司,合同可免</p>
</template>
<template v-else>
<PlPlanContractMultiSelect
v-model="expandDraft[row.plan.id].demanderContracts"
:customer-id="asGapItem(row).customerId"
:disabled="!asGapItem(row).customerId"
:required="false"
/>
<div class="pl-contract-side__actions">
<el-button
class="btn-main"
type="primary"
size="small"
:loading="savingKey === `${row.plan.id}:demander`"
@click="handleSaveSide(asGapItem(row), 'demander')"
>
<el-icon><Check /></el-icon>保存需方合同
</el-button>
</div>
</template>
</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column
type="index"
label="序号"
@@ -278,28 +185,19 @@
>
<template #default="{ row }">
<div class="pl-gap-contract-cell">
<div class="pl-plan-contract-cell">
<div
v-for="(line, index) in getContractGapStopLines(row)"
:key="`${line.side}-${line.no}-${index}`"
class="pl-plan-contract-cell__row"
>
<span class="pl-plan-contract-cell__side">{{ line.side }}</span>
<span
class="pl-plan-contract-cell__no"
:class="{ 'is-pending': line.pending }"
>{{ line.no }}</span>
</div>
</div>
<PlPlanContractCell
:lines="getContractGapStopLines(row)"
@open="openContractDetail"
/>
<el-button
v-if="showGapSupplementAction(row)"
link
type="warning"
class="pl-gap-contract-cell__action"
@click.stop="toggleExpand(asGapItem(row))"
@click.stop="openSupplementDialog(row)"
>
<el-icon><EditPen /></el-icon>
{{ isExpanded(asGapItem(row).id) ? '收起' : '补充' }}
补充
</el-button>
</div>
</template>
@@ -382,6 +280,7 @@
>
<template #default="{ row }">
<PlPlanListProductCell
:stop-kind="row.kind"
:stop-products="getStopProducts(row)"
:plan-products="asGapItem(row).products"
field="actualQty"
@@ -441,22 +340,108 @@
:plan-id="viewPlanId"
show-inbound-outbound-qty
/>
<el-dialog
v-model="supplementVisible"
class="dialog-form-layout pl-contract-supplement-dialog"
modal-class="order-module-dialog-mask"
width="960px"
align-center
destroy-on-close
:close-on-click-modal="false"
:before-close="handleSupplementBeforeClose"
>
<template #header>
<span class="pl-contract-supplement-dialog__title">
补充合同 · {{ supplementStopKindLabel }} · {{ supplementPlanNo }}
</span>
</template>
<div v-if="supplementRow" class="pl-contract-supplement-body">
<div class="pl-contract-supplement-summary">
<div class="pl-contract-supplement-kv">
<div class="k">计划日期</div>
<div class="v">{{ asGapItem(supplementRow).planDate || '—' }}</div>
</div>
<div class="pl-contract-supplement-kv">
<div class="k">跟进状态</div>
<div class="v">
<el-tag
:type="getPlPlanFollowStatusTagType(asGapItem(supplementRow).followStatus)"
size="small"
effect="dark"
>
{{ getPlPlanFollowStatusLabel(asGapItem(supplementRow).followStatus) }}
</el-tag>
</div>
</div>
<div class="pl-contract-supplement-kv">
<div class="k">本段客户</div>
<div class="v">{{ getStopCustomerName(supplementRow) }}</div>
</div>
<div class="pl-contract-supplement-kv">
<div class="k">地址 / 时间</div>
<div class="v">
{{ getStopAddress(supplementRow) }}
<template v-if="getStopTime(supplementRow)">
· {{ getStopTime(supplementRow) }}
</template>
</div>
</div>
</div>
<div class="pl-contract-side" :class="supplementSideBoxClass">
<div class="pl-contract-side__head">
<h4>{{ supplementStopKindLabel }}合同</h4>
<el-tag :type="supplementSideTagType" size="small" effect="dark">
{{ supplementSideTagLabel }}
</el-tag>
</div>
<p class="pl-contract-side__customer">{{ getStopCustomerName(supplementRow) }}</p>
<PlPlanContractMultiSelect
v-model="supplementDraft.contracts"
:customer-id="supplementRow.stop.customerId"
:disabled="!supplementRow.stop.customerId"
:required="false"
/>
</div>
</div>
<template #footer>
<el-button class="btn-ghost" @click="requestCloseSupplement">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button
class="btn-main"
type="primary"
:disabled="!supplementDraft.stopId || !supplementRow?.stop.customerId"
:loading="supplementSaving"
@click="handleSaveStopContracts"
>
<el-icon><Check /></el-icon>保存本段合同
</el-button>
</template>
</el-dialog>
<ContractDetailDialog ref="contractDetailRef" hide-product-price />
</div>
</template>
<script setup lang="ts">
import { Check, EditPen, Refresh, RefreshLeft, Search } from '@element-plus/icons-vue'
import { Check, Close, EditPen, Refresh, RefreshLeft, Search } from '@element-plus/icons-vue'
import {
planApi,
type PlanContractGapItem,
type PlanContractLink
} from '@/api/pl'
import PlPlanContractCell from '@/components/business/pl-plan-contract-cell/index.vue'
import PlPlanContractMultiSelect from '@/components/business/pl-plan-contract-multi-select/index.vue'
import PlPlanListProductCell from '@/components/business/pl-plan-list-product-cell/index.vue'
import PlPlanViewDialog from '@/components/business/pl-plan-view-dialog/index.vue'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import ListExportDropdown from '@/components/business/list-export-dropdown/index.vue'
import ListToolbarDateShortcuts from '@/components/core/list-toolbar-date-shortcuts/index.vue'
import { useContractDetailDialog } from '@/composables/useContractDetailDialog'
import { useListDateShortcut } from '@/composables/useListDateShortcut'
import { usePlPlanMergeListTable } from '@/composables/usePlPlanMergeListTable'
import {
@@ -481,24 +466,29 @@ import { useMenuStore } from '@/store/modules/menu'
const PLAN_MERGE_COLUMN_PROPS = ['planDate', 'planType', 'followStatus'] as const
const menuStore = useMenuStore()
const { ContractDetailDialog, contractDetailRef, openContractDetail } = useContractDetailDialog()
const loading = ref(false)
const tableRef = ref()
const tableData = ref<PlanContractGapItem[]>([])
const dateRange = ref<string[]>([])
const quickSearch = ref('')
const expandedRowKeys = ref<string[]>([])
const expandDraft = reactive<
Record<
string,
{
supplierContracts: PlanContractLink[]
demanderContracts: PlanContractLink[]
baselineSupplier: string
baselineDemander: string
}
>
>({})
const savingKey = ref('')
const supplementVisible = ref(false)
const supplementSaving = ref(false)
const supplementRow = ref<PlPlanListDisplayRow | null>(null)
const supplementDraft = reactive<{
contracts: PlanContractLink[]
baseline: string
stopType: 'load' | 'delivery'
stopId: string
planId: string
rowKey: string
}>({
contracts: [],
baseline: '',
stopType: 'load',
stopId: '',
planId: '',
rowKey: ''
})
const viewDialogRef = ref<InstanceType<typeof PlPlanViewDialog>>()
const viewVisible = ref(false)
const viewPlanId = ref('')
@@ -523,8 +513,7 @@ const {
displayRows,
planListSpanMethod,
getDisplayRowIndex,
handleDisplayRowDblClick,
findPlanFirstDisplayRow
handleDisplayRowDblClick
} = usePlPlanMergeListTable({
planList: planListForDisplay,
visiblePlanMergeProps,
@@ -533,11 +522,28 @@ const {
onView: (plan) => handleView(plan as unknown as PlanContractGapItem)
})
const supplementStopKindLabel = computed(() =>
supplementRow.value ? getStopKindLabel(supplementRow.value) : ''
)
const supplementPlanNo = computed(() =>
supplementRow.value ? asGapItem(supplementRow.value).planNo || '—' : '—'
)
const supplementSideBoxClass = computed(() => {
if (!supplementDraft.contracts.length) return 'is-need'
return 'is-ok'
})
const supplementSideTagLabel = computed(() =>
supplementDraft.contracts.length ? '已选' : '待补充'
)
const supplementSideTagType = computed(() =>
supplementDraft.contracts.length ? 'success' : 'warning'
)
function asGapItem(row: PlPlanListDisplayRow): PlanContractGapItem {
return row.plan as unknown as PlanContractGapItem
}
/** 段内合同缺口:装货行展示供方,送货行展示需方 */
/** 段内合同:优先段上 contracts;无则回退旧计划级供/需合同 */
function getContractGapStopLines(row: PlPlanListDisplayRow) {
if ((row.stop.contracts?.length ?? 0) > 0) {
return getStopContractLines(row)
@@ -557,16 +563,19 @@ function getContractGapStopLines(row: PlPlanListDisplayRow) {
)
}
function showGapSupplementAction(row: PlPlanListDisplayRow): boolean {
const gap = asGapItem(row)
if (row.kind === 'load') {
return !gap.supplierContractExempt && (gap.gapSides || []).includes('supplier')
}
return !gap.demanderContractExempt && (gap.gapSides || []).includes('demander')
function isStopContractExempt(row: PlPlanListDisplayRow): boolean {
return isPlInternalCustomerName(getStopCustomerName(row))
}
function gapExpandRowKey(planId: string) {
return `${planId}-load`
function stopHasContracts(row: PlPlanListDisplayRow): boolean {
return getContractGapStopLines(row).some((line) => !!line.contractId && !line.pending)
}
/** 仅非内置客户、且本段缺合同时显示「补充」 */
function showGapSupplementAction(row: PlPlanListDisplayRow): boolean {
if (isStopContractExempt(row)) return false
if (!row.stop.id) return false
return !stopHasContracts(row)
}
function getContractGapExportFilterParams() {
@@ -591,17 +600,17 @@ const contractGapExportColumns = [
'gapType'
]
function cloneLinks(links?: PlanContractLink[]): PlanContractLink[] {
return (links || []).map(item => ({
return (links || []).map((item) => ({
contractId: item.contractId,
contractNo: item.contractNo
contractNo: item.contractNo,
stopId: item.stopId
}))
}
function linksFingerprint(links: PlanContractLink[]) {
return cloneLinks(links)
.map(item => item.contractId)
.map((item) => item.contractId)
.sort()
.join(',')
}
@@ -615,116 +624,75 @@ function getPlanTypeLabel(type?: string | null) {
return type ? map[type] || type : '—'
}
function sideBoxClass(row: PlanContractGapItem, side: 'supplier' | 'demander') {
const exempt = side === 'supplier' ? row.supplierContractExempt : row.demanderContractExempt
if (exempt) return 'is-exempt'
const missing = (row.gapSides || []).includes(side)
return missing ? 'is-need' : 'is-ok'
function resolveStopDraftContracts(row: PlPlanListDisplayRow): PlanContractLink[] {
if ((row.stop.contracts?.length ?? 0) > 0) {
return cloneLinks(row.stop.contracts)
}
const gap = asGapItem(row)
if (row.kind === 'load') return cloneLinks(gap.supplierContracts)
return cloneLinks(gap.demanderContracts)
}
function sideTagLabel(row: PlanContractGapItem, side: 'supplier' | 'demander') {
const exempt = side === 'supplier' ? row.supplierContractExempt : row.demanderContractExempt
if (exempt) return '可免'
return (row.gapSides || []).includes(side) ? '待补充' : '已齐'
function isSupplementDirty() {
return linksFingerprint(supplementDraft.contracts) !== supplementDraft.baseline
}
function sideTagType(row: PlanContractGapItem, side: 'supplier' | 'demander') {
const exempt = side === 'supplier' ? row.supplierContractExempt : row.demanderContractExempt
if (exempt) return 'info'
return (row.gapSides || []).includes(side) ? 'warning' : 'success'
function resetSupplementDraft() {
supplementRow.value = null
supplementDraft.contracts = []
supplementDraft.baseline = ''
supplementDraft.stopType = 'load'
supplementDraft.stopId = ''
supplementDraft.planId = ''
supplementDraft.rowKey = ''
}
function isExpanded(id: string) {
return expandedRowKeys.value.includes(gapExpandRowKey(id))
function openSupplementDialog(row: PlPlanListDisplayRow) {
if (!row.stop.id) {
ElMessage.warning('该段次缺少段 ID,无法补充合同')
return
}
if (isStopContractExempt(row)) {
ElMessage.info('内置客户无需补充合同')
return
}
const contracts = resolveStopDraftContracts(row)
supplementRow.value = row
supplementDraft.contracts = contracts
supplementDraft.baseline = linksFingerprint(contracts)
supplementDraft.stopType = row.kind
supplementDraft.stopId = String(row.stop.id || '').trim()
supplementDraft.planId = row.plan.id
supplementDraft.rowKey = row.rowKey
supplementVisible.value = true
}
function ensureDraft(row: PlanContractGapItem) {
if (expandDraft[row.id]) return
const supplierContracts = cloneLinks(row.supplierContracts)
const demanderContracts = cloneLinks(row.demanderContracts)
expandDraft[row.id] = {
supplierContracts,
demanderContracts,
baselineSupplier: linksFingerprint(supplierContracts),
baselineDemander: linksFingerprint(demanderContracts)
async function confirmDiscardIfDirty(): Promise<boolean> {
if (!isSupplementDirty()) return true
try {
await ElMessageBox.confirm('当前有未保存的合同变更,确认关闭吗?', '提示', {
type: 'warning',
confirmButtonText: '关闭',
cancelButtonText: '继续编辑'
})
return true
} catch {
return false
}
}
function isDraftDirty(id: string) {
const draft = expandDraft[id]
if (!draft) return false
return (
linksFingerprint(draft.supplierContracts) !== draft.baselineSupplier ||
linksFingerprint(draft.demanderContracts) !== draft.baselineDemander
)
async function requestCloseSupplement() {
const ok = await confirmDiscardIfDirty()
if (!ok) return
supplementVisible.value = false
resetSupplementDraft()
}
async function collapseRow(id: string) {
if (isDraftDirty(id)) {
try {
await ElMessageBox.confirm('当前有未保存的合同变更,确认收起吗?', '提示', {
type: 'warning',
confirmButtonText: '收起',
cancelButtonText: '继续编辑'
})
} catch {
return false
}
}
delete expandDraft[id]
expandedRowKeys.value = expandedRowKeys.value.filter((key) => key !== gapExpandRowKey(id))
return true
}
async function expandRow(row: PlanContractGapItem) {
const currentKey = expandedRowKeys.value[0]
const currentId = currentKey?.replace(/-load$/, '')
if (currentId && currentId !== row.id) {
const ok = await collapseRow(currentId)
if (!ok) return
}
ensureDraft(row)
expandedRowKeys.value = [gapExpandRowKey(row.id)]
}
async function toggleExpand(row: PlanContractGapItem) {
if (isExpanded(row.id)) {
await collapseRow(row.id)
} else {
await expandRow(row)
}
}
async function handleExpandChange(
displayRow: PlPlanListDisplayRow,
expandedRows: PlPlanListDisplayRow[]
) {
const gap = asGapItem(displayRow)
const willExpand = expandedRows.some((item) => item.rowKey === displayRow.rowKey)
if (willExpand) {
const currentKey = expandedRowKeys.value[0]
const currentId = currentKey?.replace(/-load$/, '')
if (currentId && currentId !== gap.id) {
const ok = await collapseRow(currentId)
if (!ok) {
nextTick(() => {
const firstRow = findPlanFirstDisplayRow(gap.id)
if (firstRow) tableRef.value?.toggleRowExpansion?.(firstRow, false)
})
return
}
}
ensureDraft(gap)
expandedRowKeys.value = [gapExpandRowKey(gap.id)]
} else if (expandedRowKeys.value.includes(gapExpandRowKey(gap.id))) {
const ok = await collapseRow(gap.id)
if (!ok) {
nextTick(() => {
const firstRow = findPlanFirstDisplayRow(gap.id)
if (firstRow) tableRef.value?.toggleRowExpansion?.(firstRow, true)
})
}
}
async function handleSupplementBeforeClose(done: () => void) {
const ok = await confirmDiscardIfDirty()
if (!ok) return
resetSupplementDraft()
done()
}
async function fetchData() {
@@ -744,15 +712,6 @@ async function fetchData() {
const data = (res as any).data ?? res
tableData.value = data.list || []
pagination.total = data.total || 0
// 收起已不在列表中的展开行
const ids = new Set(tableData.value.map((item) => item.id))
expandedRowKeys.value = expandedRowKeys.value.filter((key) => {
const planId = key.replace(/-load$/, '')
return ids.has(planId)
})
Object.keys(expandDraft).forEach(id => {
if (!ids.has(id)) delete expandDraft[id]
})
} finally {
loading.value = false
}
@@ -804,37 +763,37 @@ function handleView(row: PlanContractGapItem) {
viewVisible.value = true
}
async function handleSaveSide(row: PlanContractGapItem, side: 'supplier' | 'demander') {
const draft = expandDraft[row.id]
if (!draft) return
const contracts =
side === 'supplier' ? draft.supplierContracts : draft.demanderContracts
savingKey.value = `${row.id}:${side}`
async function handleSaveStopContracts() {
if (!supplementDraft.stopId) {
ElMessage.warning('该段次缺少段 ID,无法保存')
return
}
if (!supplementRow.value?.stop.customerId) {
ElMessage.warning('请先维护本段客户后再选择合同')
return
}
if (!supplementDraft.contracts.length) {
ElMessage.warning('请先选择至少 1 份合同')
return
}
supplementSaving.value = true
try {
const res = await planApi.updateSideContracts(row.id, side, contracts)
const updated = ((res as any).data ?? res) as PlanContractGapItem
ElMessage.success(side === 'supplier' ? '供方合同已保存' : '需方合同已保存')
await planApi.updateStopContracts(supplementDraft.planId, {
stopType: supplementDraft.stopType,
stopId: supplementDraft.stopId,
contracts: supplementDraft.contracts
})
ElMessage.success('本段合同已保存')
if (!(updated.gapSides || []).length) {
tableData.value = tableData.value.filter(item => item.id !== row.id)
pagination.total = Math.max(0, pagination.total - 1)
delete expandDraft[row.id]
expandedRowKeys.value = []
await refreshMenuBadges(menuStore.menuList)
return
}
// 先对齐 baseline,避免关闭时被 before-close 当成未保存变更
supplementDraft.baseline = linksFingerprint(supplementDraft.contracts)
supplementVisible.value = false
resetSupplementDraft()
const idx = tableData.value.findIndex(item => item.id === row.id)
if (idx >= 0) {
tableData.value[idx] = updated
}
draft.supplierContracts = cloneLinks(updated.supplierContracts)
draft.demanderContracts = cloneLinks(updated.demanderContracts)
draft.baselineSupplier = linksFingerprint(draft.supplierContracts)
draft.baselineDemander = linksFingerprint(draft.demanderContracts)
await fetchData()
await refreshMenuBadges(menuStore.menuList)
} finally {
savingKey.value = ''
supplementSaving.value = false
}
}
@@ -873,21 +832,48 @@ onMounted(() => {
border: 1px solid rgba(217, 119, 6, 0.2);
}
.pl-contract-expand {
padding: 12px 16px 16px;
background: #f8fffb;
border-top: 1px dashed #a7f3d0;
.pl-contract-supplement-dialog__title {
font-size: 16px;
font-weight: 600;
color: var(--text, #1e293b);
}
.pl-contract-expand__grid {
.pl-contract-supplement-body {
display: flex;
flex-direction: column;
gap: 16px;
}
.pl-contract-supplement-summary {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
padding: 12px 14px;
background: #f8fafc;
border: 1px solid #eef2f6;
border-radius: 8px;
}
@media (max-width: 960px) {
.pl-contract-expand__grid {
grid-template-columns: 1fr;
@media (max-width: 700px) {
.pl-contract-supplement-summary {
grid-template-columns: 1fr 1fr;
}
}
.pl-contract-supplement-kv {
min-width: 0;
.k {
font-size: 11px;
color: var(--mint-text-muted, #64748b);
}
.v {
margin-top: 2px;
font-size: 13px;
font-weight: 500;
color: var(--text, #1e293b);
word-break: break-word;
}
}
@@ -895,7 +881,7 @@ onMounted(() => {
background: #fff;
border: 1px solid var(--el-border-color, #d9e8e2);
border-radius: 8px;
padding: 12px;
padding: 14px;
}
.pl-contract-side.is-need {
@@ -908,11 +894,6 @@ onMounted(() => {
background: #f6fffb;
}
.pl-contract-side.is-exempt {
border-color: #e2e8f0;
background: #f8fafc;
}
.pl-contract-side__head {
display: flex;
align-items: center;
@@ -922,43 +903,16 @@ onMounted(() => {
h4 {
margin: 0;
font-size: 13px;
font-size: 14px;
font-weight: 600;
}
}
.pl-contract-side__customer {
margin: 0 0 10px;
margin: 0 0 12px;
font-size: 13px;
}
.pl-contract-side__exempt {
margin: 0;
padding: 14px;
border: 1px solid #e2e8f0;
border-radius: 8px;
text-align: center;
color: var(--el-text-color-secondary, #64748b);
font-size: 12px;
background: #f8fafc;
}
.pl-contract-side__actions {
display: flex;
gap: 8px;
margin-top: 10px;
}
:deep(.pl-contract-expand-col) {
.cell {
padding: 0 !important;
}
.el-table__expand-icon {
display: none;
}
}
.pl-gap-contract-cell {
display: flex;
align-items: flex-start;
+12 -16
View File
@@ -483,6 +483,7 @@
>
<template #default="{ row }">
<PlPlanListProductCell
:stop-kind="row.kind"
:stop-products="getStopProducts(row)"
:plan-products="row.plan.products"
field="actualQty"
@@ -515,20 +516,10 @@
label-class-name="col-name-wrap"
>
<template #default="{ row }">
<div class="pl-plan-contract-cell">
<div
v-for="(line, index) in getStopContractLines(row)"
:key="`${line.side}-${line.no}-${index}`"
class="pl-plan-contract-cell__row"
>
<span class="pl-plan-contract-cell__side">{{ line.side }}</span>
<span
class="pl-plan-contract-cell__no"
:class="{ 'is-pending': line.pending }"
>{{ line.no }}</span
>
</div>
</div>
<PlPlanContractCell
:lines="getStopContractLines(row)"
@open="openContractDetail"
/>
</template>
</el-table-column>
<el-table-column
@@ -691,6 +682,8 @@
</template>
</PlPlanViewDialog>
<ContractDetailDialog ref="contractDetailRef" hide-product-price />
<DbPoundSlipQrcodeDialog
v-model:visible="qrcodeVisible"
:inventory-id="selectedInventoryId"
@@ -797,6 +790,7 @@
import PlDeliverySlipPrintDialog from '@/components/business/pl-delivery-slip-print-dialog/index.vue'
import PlSubpackSlipPrintDialog from '@/components/business/pl-subpack-slip-print-dialog/index.vue'
import PlPlanDeliveryCustomerSelectDialog from '@/components/business/pl-plan-delivery-customer-select-dialog/index.vue'
import PlPlanContractCell from '@/components/business/pl-plan-contract-cell/index.vue'
import PlPasteRecognizeDialog from '@/components/business/pl-paste-recognize-dialog/index.vue'
import PlPlanFormDialog from '@/components/business/pl-plan-form-dialog/index.vue'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
@@ -804,6 +798,7 @@
import ListExportDropdown from '@/components/business/list-export-dropdown/index.vue'
import ListColumnStatsRow from '@/components/business/list-column-stats-row/index.vue'
import type { StatsRowColumn } from '@/utils/list-column-stats'
import { useContractDetailDialog } from '@/composables/useContractDetailDialog'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import { usePlPlanArrangeActions } from '@/composables/usePlPlanArrangeActions'
import PlPlanListCertCell from '@/components/business/pl-plan-list-cert-cell/index.vue'
@@ -831,7 +826,7 @@
getStopProducts,
getStopTime,
resolveStopCertProducts,
sumStopActualQty,
sumStopArchiveActualQty,
sumStopProductsField,
type PlPlanListDisplayRow
} from '@/utils/pl-plan-list-display-rows'
@@ -860,6 +855,7 @@
] as const
const route = useRoute()
const router = useRouter()
const { ContractDetailDialog, contractDetailRef, openContractDetail } = useContractDetailDialog()
const loading = ref(false)
const voidLoading = ref(false)
@@ -1023,7 +1019,7 @@
if (prop === 'productPlanQty') return sumStopProductsField(stopProducts, 'planQty')
if (prop === 'productInboundQty') return sumStopProductsField(stopProducts, 'inboundQty')
if (prop === 'productOutboundQty') return sumStopProductsField(stopProducts, 'outboundQty')
if (prop === 'productActualQty') return sumStopActualQty(plan, stopProducts)
if (prop === 'productActualQty') return sumStopArchiveActualQty(stopProducts, row.kind)
if (prop === 'productName') {
return stopProducts
.map((p) => p.productName || '')