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

+21 -8
View File
@@ -231,28 +231,31 @@
"229": "useClientColumnFilters.ts",
"230": "SettingActions.vue",
"231": "index.vue",
"232": "index.vue",
"232": "handleConfirmApproval",
"233": "Community 233",
"234": "Community 234",
"235": "resolveDictValue",
"236": "index.vue",
"237": "colors.ts",
"238": "price-letter-template.ts",
"239": "Community 239",
"240": "detail.vue",
"241": "Ha",
"242": "Community 242",
"243": "Community 243",
"244": "v2.4.2.1 - 2025-06-16",
"245": "index.js",
"246": "fetchData",
"247": "Community 247",
"248": "Community 248",
"249": "SettingItem.vue",
"250": "index.vue",
"250": "loadDefaultCustomerOptions",
"251": "handleDisburse",
"252": "index.js",
"253": "Community 253",
"254": "v2.4.2.1 - 2025-06-16",
"254": "db.ts",
"255": "Community 255",
"256": "db.ts",
"256": "Ha",
"257": "handleExportPdf",
"258": "index.vue",
"259": "Community 259",
@@ -260,30 +263,41 @@
"261": "Community 261",
"262": "index.js",
"263": "StorageConfig",
"264": "assignmentMatchesFilter",
"265": "Community 265",
"266": "crypto.ts",
"267": "SettingActions.vue",
"267": "index.vue",
"268": "index.vue",
"269": "Community 269",
"270": "Community 270",
"271": "index.vue",
"272": "syncStatsColumnWidths",
"273": "product.ts",
"274": "zh_CN.js",
"275": "index.js",
"276": "index.vue",
"277": "index.vue",
"278": "fetchData",
"279": "error-handle.ts",
"280": "Community 280",
"281": "index.js",
"282": "handleSearch",
"283": "Community 283",
"284": "Community 284",
"285": "Community 285",
"286": "Community 286",
"287": "pad",
"288": "form.vue",
"289": "handleEdit",
"290": "v2.4.2.3 - 2025-06-18",
"291": "index.vue",
"292": "Community 292",
"293": "index.vue",
"294": "index.vue",
"295": "Community 295",
"296": "Community 296",
"297": "menuFavorite.ts",
"300": "index.vue",
"297": "getPlMaintenanceApplyStatusLabel",
"300": "zh_CN.js",
"302": "Community 302",
"303": "Community 303",
"304": "Community 304",
@@ -485,7 +499,6 @@
"786": "⚠️ 重要提示",
"788": "⚠️ 重要提示",
"796": "OpportunityForm.vue",
"806": "index.vue",
"836": "BxInvoiceFileSelect.vue",
"838": "CustomerSelector.vue",
"840": "index.vue",
+1 -1
View File
@@ -1 +1 @@
/Users/wuzilong/work/00code/shanxia2.0/web-admin
.
@@ -231,31 +231,31 @@
"229": "useClientColumnFilters.ts",
"230": "SettingActions.vue",
"231": "index.vue",
"232": "index.vue",
"232": "handleConfirmApproval",
"233": "Community 233",
"234": "Community 234",
"235": "index.js",
"235": "resolveDictValue",
"236": "index.vue",
"237": "DbPoundSlipQrcodeDialog.vue",
"237": "colors.ts",
"238": "price-letter-template.ts",
"239": "Community 239",
"240": "detail.vue",
"241": "Ha",
"242": "Community 242",
"243": "Community 243",
"244": "loadUserOptions",
"244": "v2.4.2.1 - 2025-06-16",
"245": "index.js",
"246": "fetchData",
"247": "Community 247",
"248": "Community 248",
"249": "SettingItem.vue",
"250": "index.vue",
"250": "loadDefaultCustomerOptions",
"251": "handleDisburse",
"252": "index.js",
"253": "Community 253",
"254": "v2.4.2.1 - 2025-06-16",
"254": "db.ts",
"255": "Community 255",
"256": "db.ts",
"256": "Ha",
"257": "handleExportPdf",
"258": "index.vue",
"259": "Community 259",
@@ -263,43 +263,40 @@
"261": "Community 261",
"262": "index.js",
"263": "StorageConfig",
"264": "VersionManager",
"264": "assignmentMatchesFilter",
"265": "Community 265",
"266": "crypto.ts",
"267": "SettingActions.vue",
"268": "saveNodes",
"267": "index.vue",
"268": "index.vue",
"269": "Community 269",
"270": "Community 270",
"271": "saveNodes",
"272": "index.vue",
"271": "index.vue",
"272": "syncStatsColumnWidths",
"273": "product.ts",
"274": "storage.ts",
"274": "zh_CN.js",
"275": "index.js",
"276": "index.vue",
"277": "UploadDialog.vue",
"277": "index.vue",
"278": "fetchData",
"279": "error-handle.ts",
"280": "Community 280",
"281": "index.js",
"282": "handleDisburse",
"282": "handleSearch",
"283": "Community 283",
"284": "Community 284",
"285": "Community 285",
"286": "Community 286",
"287": "index.ts",
"288": "fetchData",
"287": "pad",
"288": "form.vue",
"289": "handleEdit",
"290": "index.vue",
"291": "syncStatsColumnWidths",
"290": "v2.4.2.3 - 2025-06-18",
"291": "index.vue",
"292": "Community 292",
"293": "index.vue",
"294": "index.vue",
"295": "Community 295",
"296": "Community 296",
"297": "menuFavorite.ts",
"298": "index.vue",
"299": "index.vue",
"300": "index.vue",
"300": "zh_CN.js",
"302": "Community 302",
"303": "Community 303",
"304": "Community 304",
@@ -501,7 +498,6 @@
"786": "⚠️ 重要提示",
"788": "⚠️ 重要提示",
"796": "OpportunityForm.vue",
"806": "index.vue",
"836": "BxInvoiceFileSelect.vue",
"838": "CustomerSelector.vue",
"840": "index.vue",
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+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 || '')