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

+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 || '')