This commit is contained in:
zlwu committed 2026-08-30 18:33:13 +08:00
1 parent c8cec132bf
commit 19c5aaa8f5
95 files changed
+309129 -9838

No files matched your search

+3 -7
View File
@@ -281,7 +281,7 @@
"279": "crm-owner-candidates.ts",
"280": "Community 280",
"281": "index.js",
"282": "index.vue",
"282": "fetchData",
"283": "Community 283",
"284": "Community 284",
"285": "Community 285",
@@ -296,7 +296,7 @@
"294": "menuFavorite.ts",
"295": "Community 295",
"296": "Community 296",
"297": "product.ts",
"297": "usePersistedListColumnWidths.ts",
"298": "index.vue",
"299": "zh_CN.js",
"300": "form.vue",
@@ -311,7 +311,7 @@
"309": "Community 309",
"310": "Community 310",
"311": "handleExportPdf",
"312": "index.vue",
"312": "ArtException.vue",
"313": "price-letter.ts",
"314": "assignmentMatchesFilter",
"315": "Community 315",
@@ -342,12 +342,9 @@
"340": "Community 340",
"341": "Community 341",
"342": "error-handle.ts",
"343": "index.vue",
"344": "ArtException.vue",
"345": "zh_CN.js",
"346": "Community 346",
"347": "Community 347",
"348": "handleSearch",
"349": "Community 349",
"350": "Community 350",
"355": "Community 355",
@@ -601,7 +598,6 @@
"1284": "plugin.min.js",
"1287": "plugin.js",
"1288": "plugin.min.js",
"1290": "index.js",
"1292": "index.js",
"1293": "plugin.js",
"1294": "plugin.min.js",
@@ -281,7 +281,6 @@
"279": "crm-owner-candidates.ts",
"280": "Community 280",
"281": "index.js",
"282": "index.vue",
"283": "Community 283",
"284": "Community 284",
"285": "Community 285",
@@ -296,6 +295,7 @@
"294": "menuFavorite.ts",
"295": "Community 295",
"296": "Community 296",
"298": "index.vue",
"299": "zh_CN.js",
"300": "form.vue",
"301": "ProcessStatusTag.vue",
@@ -308,6 +308,8 @@
"308": "Community 308",
"309": "Community 309",
"310": "Community 310",
"311": "handleExportPdf",
"313": "price-letter.ts",
"314": "assignmentMatchesFilter",
"315": "Community 315",
"316": "Community 316",
@@ -338,9 +340,9 @@
"341": "Community 341",
"342": "error-handle.ts",
"344": "ArtException.vue",
"345": "zh_CN.js",
"346": "Community 346",
"347": "Community 347",
"348": "handleSearch",
"349": "Community 349",
"350": "Community 350",
"355": "Community 355",
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
+8
View File
@@ -871,9 +871,17 @@ export interface ContractProductArrangeQtyPlanItem {
planNo?: string
planDate?: string | null
planQty: number
planType?: string | null
belongsCompany?: string | null
supplierName?: string | null
customerName?: string | null
licensePlate?: string | null
driverName?: string | null
followUserName?: string | null
followStatus?: string
transportStatus?: string
archiveStatus?: string
createTime?: string | null
}
export interface ContractProductArrangeQtyDetail {
@@ -8,8 +8,10 @@
background-color: #ecfdf5 !important;
}
tbody tr.pl-plan-list-row:hover > td.el-table__cell {
background-color: var(--el-table-row-hover-bg-color, #f1f5f9) !important;
/* 悬停用浅红;须压过斑马纹/装货段 !important 底色 */
tbody tr.pl-plan-list-row:hover > td.el-table__cell,
tbody tr.pl-plan-list-row.hover-row > td.el-table__cell {
background-color: #fee2e2 !important;
}
.pl-plan-list-row--plan-end > td {
@@ -470,6 +470,17 @@ html body .el-tag {
padding: 25px 20px !important;
}
/* 运输计划 · 合同数量校验/提醒:加宽以容纳产品明细表 */
.el-message-box.pl-contract-qty-msgbox {
width: 780px !important;
max-width: 92vw !important;
}
.el-message-box.pl-contract-qty-msgbox .el-message-box__message {
max-height: min(60vh, 520px);
overflow: auto;
}
.el-message-box__title {
font-weight: 500 !important;
}
@@ -0,0 +1,535 @@
<!-- eslint-disable vue/no-v-model-argument -->
<!-- 合同详情 · 关联运输计划列表(列与 /#/pl/plan 一致;壳层对齐 dialog-list-panel / OpenSpec) -->
<template>
<div class="pl-contract-linked-plans-panel panel dialog-list-panel">
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
共关联 <span class="sel-num">{{ pagination.total }}</span> 条运输计划
</div>
</div>
</div>
<div class="list-body list-wrap">
<div class="table-scroll bx-list-table-wrap">
<ListTable
table-key="crm-contract-linked-plans"
class="list-table pl-plan-merge-list-table"
:data="displayRows"
v-loading="loading"
row-key="rowKey"
:span-method="planListSpanMethod"
:row-class-name="getPlPlanListRowClassName"
empty-text="暂无关联运输计划"
@row-dblclick="handleDisplayRowDblClick"
>
<el-table-column
type="index"
label="序号"
width="68"
align="center"
header-align="center"
:index="getDisplayRowIndex"
/>
<el-table-column prop="planNo" label="计划编号" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<el-link type="primary" @click="handleView(row.plan)">{{ row.plan.planNo }}</el-link>
</template>
</el-table-column>
<el-table-column prop="followStatus" label="跟进状态" min-width="100" align="center">
<template #default="{ row }">
<el-tag :type="getPlPlanFollowStatusTagType(row.plan.followStatus)" size="small">
{{ getPlPlanFollowStatusLabel(row.plan.followStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="planDate" label="计划日期" min-width="120" align="center">
<template #default="{ row }">{{ row.plan.planDate || '—' }}</template>
</el-table-column>
<el-table-column prop="planType" label="单据类型" min-width="100" align="center">
<template #default="{ row }">{{ getPlanTypeLabel(row.plan.planType) }}</template>
</el-table-column>
<el-table-column
prop="licensePlate"
label="车牌号"
min-width="160"
show-overflow-tooltip
align="center"
>
<template #default="{ row }">
{{ formatPlVehiclePlateDisplay(row.plan.licensePlate, row.plan.trailerNo) }}
</template>
</el-table-column>
<el-table-column
prop="personnelSummary"
label="人员"
min-width="180"
show-overflow-tooltip
align="center"
>
<template #default="{ row }">
{{ normalizePlPersonnelSummaryDisplay(row.plan.personnelSummary) }}
</template>
</el-table-column>
<el-table-column prop="followUserName" label="跟进人" min-width="100" align="center">
<template #default="{ row }">{{ row.plan.followUserName || '—' }}</template>
</el-table-column>
<el-table-column
prop="arrangeCopy"
label="链接复制"
min-width="88"
align="center"
header-align="center"
>
<template #default="{ row }">
<div v-if="row.plan.canCopyPoundQrcode" class="pl-plan-arrange-copy-cell">
<el-button
class="btn-ghost"
title="复制磅单二维码"
:loading="
arrangeCopyLoadingId === row.plan.id && arrangeCopyLoadingAction === 'qrcode'
"
@click.stop="handleListCopyPoundQrcode(row.plan)"
>
磅单码
</el-button>
</div>
<span v-else>—</span>
</template>
</el-table-column>
<el-table-column
prop="stopKind"
label="段次"
min-width="88"
align="center"
class-name="pl-plan-stop-col"
>
<template #default="{ row }">
<span class="pl-plan-stop-kind-text">{{ getStopKindLabel(row) }}</span>
</template>
</el-table-column>
<el-table-column
prop="stopCustomer"
label="客户"
min-width="200"
align="left"
header-align="left"
class-name="col-name-wrap pl-plan-stop-col"
label-class-name="col-name-wrap"
show-overflow-tooltip
>
<template #default="{ row }">
<span>{{ getStopCustomerName(row) }}</span>
<el-tag
v-if="isPlInternalCustomerName(getStopCustomerName(row))"
type="success"
size="small"
class="pl-plan-internal-customer-tag"
>
内部
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="stopAddressTime"
label="地址 / 时间"
min-width="220"
align="left"
header-align="left"
class-name="col-name-wrap pl-plan-stop-col"
label-class-name="col-name-wrap"
>
<template #default="{ row }">
<div class="pl-plan-stop-address-time">
<div class="pl-plan-stop-address-time__addr">{{ getStopAddress(row) }}</div>
<div class="pl-plan-stop-address-time__time">{{ getStopTime(row) }}</div>
</div>
</template>
</el-table-column>
<el-table-column
label="产品名称"
min-width="160"
align="left"
header-align="left"
class-name="col-name-wrap pl-plan-stop-col"
label-class-name="col-name-wrap"
>
<template #default="{ row }">
<PlPlanListProductCell
:stop-products="getStopProducts(row)"
:plan-products="row.plan.products"
field="name"
/>
</template>
</el-table-column>
<el-table-column
label="型号/规格"
min-width="96"
align="center"
header-align="center"
class-name="pl-plan-stop-col"
show-overflow-tooltip
>
<template #default="{ row }">
<PlPlanListProductCell
:stop-products="getStopProducts(row)"
:plan-products="row.plan.products"
field="spec"
/>
</template>
</el-table-column>
<el-table-column
label="包装规格"
min-width="120"
align="center"
header-align="center"
class-name="pl-plan-stop-col"
show-overflow-tooltip
>
<template #default="{ row }">
<PlPlanListProductCell
:stop-products="getStopProducts(row)"
:plan-products="row.plan.products"
field="packageSpec"
/>
</template>
</el-table-column>
<el-table-column
label="单位"
min-width="64"
align="center"
header-align="center"
class-name="pl-plan-stop-col"
>
<template #default="{ row }">
<PlPlanListProductCell
:stop-products="getStopProducts(row)"
:plan-products="row.plan.products"
field="unit"
/>
</template>
</el-table-column>
<el-table-column
label="计划数量"
min-width="100"
align="right"
header-align="right"
class-name="pl-plan-stop-col"
>
<template #default="{ row }">
<PlPlanListProductCell
:stop-products="getStopProducts(row)"
:plan-products="row.plan.products"
field="planQty"
/>
</template>
</el-table-column>
<el-table-column
label="实际数量"
min-width="100"
align="right"
header-align="right"
class-name="pl-plan-stop-col"
>
<template #default="{ row }">
<PlPlanListProductCell
:stop-kind="row.kind"
:stop-products="getStopProducts(row)"
:plan-products="row.plan.products"
field="actualQty"
/>
</template>
</el-table-column>
<el-table-column
label="计划附件"
min-width="200"
align="left"
header-align="left"
class-name="pl-plan-stop-col"
>
<template #default="{ row }">
<PlPlanListCertCell
:products="resolveStopCertProducts(row.plan, getStopProducts(row))"
compact
hide-product
/>
</template>
</el-table-column>
<el-table-column
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)" />
</template>
</el-table-column>
<el-table-column
label="入库数量"
min-width="100"
align="right"
header-align="right"
class-name="pl-plan-stop-col"
>
<template #default="{ row }">
<PlPlanListProductCell
:stop-products="getStopProducts(row)"
:plan-products="row.plan.products"
field="inboundQty"
/>
</template>
</el-table-column>
<el-table-column
label="出库数量"
min-width="100"
align="right"
header-align="right"
class-name="pl-plan-stop-col"
>
<template #default="{ row }">
<PlPlanListProductCell
:stop-products="getStopProducts(row)"
:plan-products="row.plan.products"
field="outboundQty"
/>
</template>
</el-table-column>
<el-table-column
prop="stopDibang"
label="地磅"
min-width="72"
align="center"
class-name="pl-plan-stop-col"
>
<template #default="{ row }">
<el-tag
v-if="formatStopDibangLabel(row.stop)"
:type="getStopDibangTagType(row.stop)"
size="small"
>
{{ formatStopDibangLabel(row.stop) }}
</el-tag>
<span v-else>—</span>
</template>
</el-table-column>
</ListTable>
</div>
<div class="pagination list-pager-bar">
<div class="list-pager-selected">
共 <span class="sel-num">{{ pagination.total }}</span> 条
</div>
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="sizes, prev, pager, next"
background
@size-change="handlePageSizeChange"
@current-change="fetchData"
/>
</div>
</div>
<PlPlanViewDialog
v-model:visible="viewVisible"
:plan-id="viewPlanId"
show-inbound-outbound-qty
/>
<DbPoundSlipQrcodeDialog
ref="qrcodeDialogRef"
v-model:visible="qrcodeVisible"
:inventory-id="selectedInventoryId"
:quantity-unit="selectedInventoryUnit"
/>
<PlPlanPoundQrcodePickDialog
v-model:visible="poundQrcodePickVisible"
:records="poundQrcodePickRecords"
:plan="arrangeActionPlan"
@copy="handlePoundQrcodePickCopy"
@preview="handlePoundQrcodePickPreview"
/>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { planApi, type Plan } from '@/api/pl'
import PlPlanViewDialog from '@/components/business/pl-plan-view-dialog/index.vue'
import PlPlanListProductCell from '@/components/business/pl-plan-list-product-cell/index.vue'
import PlPlanListCertCell from '@/components/business/pl-plan-list-cert-cell/index.vue'
import PlPlanContractCell from '@/components/business/pl-plan-contract-cell/index.vue'
import DbPoundSlipQrcodeDialog from '@/components/business/db/DbPoundSlipQrcodeDialog.vue'
import PlPlanPoundQrcodePickDialog from '@/components/business/pl-plan-pound-qrcode-pick-dialog/index.vue'
import { usePlPlanMergeListTable } from '@/composables/usePlPlanMergeListTable'
import { usePlPlanArrangeActions } from '@/composables/usePlPlanArrangeActions'
import { isPlInternalCustomerName } from '@/utils/pl-internal-customer'
import {
formatStopDibangLabel,
getPlPlanListRowClassName,
getStopAddress,
getStopContractLines,
getStopCustomerName,
getStopDibangTagType,
getStopKindLabel,
getStopProducts,
getStopTime,
resolveStopCertProducts
} from '@/utils/pl-plan-list-display-rows'
import {
formatPlVehiclePlateDisplay,
getPlPlanFollowStatusLabel,
getPlPlanFollowStatusTagType,
normalizePlPersonnelSummaryDisplay
} from '@/utils/pl-display'
import { getPlanTypeLabel } from '@/composables/usePlContractToPlan'
const props = defineProps<{
contractId?: string | null
active?: boolean
}>()
const loading = ref(false)
const planList = ref<Plan[]>([])
const selectedRows = ref<Plan[]>([])
const viewVisible = ref(false)
const viewPlanId = ref('')
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0
})
/** 与 pl/plan 列表 PLAN_MERGE_COLUMN_PROPS 顺序一致(含勾选/序号/计划编号固定 3 列) */
const PLAN_MERGE_COLUMN_PROPS = [
'followStatus',
'planDate',
'planType',
'licensePlate',
'personnelSummary',
'followUserName',
'arrangeCopy'
] as const
const visiblePlanMergeProps = computed(() => [...PLAN_MERGE_COLUMN_PROPS])
const {
displayRows,
planListSpanMethod,
getDisplayRowIndex,
handleDisplayRowDblClick
} = usePlPlanMergeListTable({
planList,
visiblePlanMergeProps,
selectedRows,
pagination,
onView: handleView,
fixedMergeColCount: 2
})
const {
plan: arrangeActionPlan,
qrcodeVisible,
selectedInventoryId,
selectedInventoryUnit,
qrcodeDialogRef,
poundQrcodePickVisible,
poundQrcodePickRecords,
loadArrangeActions,
copyPoundSlipQrcode,
handlePoundQrcodePickCopy,
handlePoundQrcodePickPreview
} = usePlPlanArrangeActions()
const arrangeCopyLoadingId = ref('')
const arrangeCopyLoadingAction = ref<'qrcode' | ''>('')
async function handleListCopyPoundQrcode(row: Plan) {
if (!row.id || arrangeCopyLoadingId.value) return
arrangeCopyLoadingId.value = row.id
arrangeCopyLoadingAction.value = 'qrcode'
try {
await loadArrangeActions(row.id)
await copyPoundSlipQrcode()
} catch {
ElMessage.error('复制失败,请稍后重试')
} finally {
arrangeCopyLoadingId.value = ''
arrangeCopyLoadingAction.value = ''
}
}
async function fetchData() {
const cid = String(props.contractId || '').trim()
if (!cid) {
planList.value = []
pagination.total = 0
return
}
loading.value = true
try {
const res = await planApi.list({
contractId: cid,
page: pagination.page,
pageSize: pagination.pageSize
})
const data = (res as any)?.data ?? res
planList.value = data?.list || []
pagination.total = data?.total || 0
} catch {
planList.value = []
pagination.total = 0
} finally {
loading.value = false
}
}
function handlePageSizeChange() {
pagination.page = 1
fetchData()
}
function handleView(plan: Plan) {
if (!plan?.id) return
viewPlanId.value = plan.id
viewVisible.value = true
}
watch(
() => [props.contractId, props.active] as const,
([cid, active]) => {
if (!active || !cid) return
pagination.page = 1
fetchData()
},
{ immediate: true }
)
defineExpose({ refresh: fetchData })
</script>
<style scoped lang="scss">
@use '@styles/business/pl-plan-merge-list-table.scss';
.pl-contract-linked-plans-panel {
.bx-list-table-wrap {
min-width: 0;
}
.pl-plan-arrange-copy-cell {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 100%;
}
.pl-plan-arrange-copy-cell .btn-ghost.el-button {
margin: 0;
}
}
</style>
@@ -1,9 +1,10 @@
<template>
<div class="pl-contract-product-arrange-qty-host">
<el-dialog
:model-value="modelValue"
class="dialog-form-layout pl-contract-product-arrange-qty-dialog"
:title="dialogTitle"
width="640px"
width="min(2000px, calc(100vw - 32px))"
align-center
destroy-on-close
append-to-body
@@ -26,25 +27,274 @@
<div class="v is-avail">{{ availableQtyText }}</div>
</div>
</div>
<div class="arrange-qty-list-title">已安排本产品的运输计划</div>
<el-table
v-if="(detail?.plans || []).length"
:data="detail?.plans || []"
class="list-table"
stripe
size="small"
max-height="280"
>
<el-table-column prop="planNo" label="计划编号" min-width="140" show-overflow-tooltip />
<el-table-column prop="planDate" label="计划日期" min-width="110" />
<el-table-column label="计划数量" min-width="100" align="right">
<template #default="{ row }">{{ formatQty(row.planQty) }}</template>
</el-table-column>
<el-table-column label="状态" min-width="100">
<template #default="{ row }">{{ statusLabel(row) }}</template>
</el-table-column>
</el-table>
<p v-else class="arrange-qty-empty">暂无已安排计划</p>
<div class="panel dialog-list-panel arrange-qty-plan-panel">
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已安排本产品的运输计划
<template v-if="arrangedPlanCount">
· 共 <span class="sel-num">{{ arrangedPlanCount }}</span> 条
</template>
</div>
</div>
</div>
<div class="list-body list-wrap">
<div class="table-scroll bx-list-table-wrap">
<ListTable
v-if="displayRows.length"
table-key="pl-contract-product-arrange-qty-plans"
class="list-table pl-plan-merge-list-table"
:data="displayRows"
row-key="rowKey"
:span-method="planListSpanMethod"
:row-class-name="getPlPlanListRowClassName"
empty-text="暂无已安排计划"
@row-dblclick="handleDisplayRowDblClick"
>
<el-table-column
type="index"
label="序号"
width="68"
align="center"
header-align="center"
:index="getDisplayRowIndex"
/>
<el-table-column prop="planNo" label="计划编号" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<el-link type="primary" @click="handleView(row.plan)">{{
row.plan.planNo
}}</el-link>
</template>
</el-table-column>
<el-table-column prop="followStatus" label="跟进状态" min-width="100" align="center">
<template #default="{ row }">
<el-tag :type="getPlPlanFollowStatusTagType(row.plan.followStatus)" size="small">
{{ getPlPlanFollowStatusLabel(row.plan.followStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="planDate" label="计划日期" min-width="120" align="center">
<template #default="{ row }">{{ row.plan.planDate || '—' }}</template>
</el-table-column>
<el-table-column prop="planType" label="单据类型" min-width="100" align="center">
<template #default="{ row }">{{ getPlanTypeLabel(row.plan.planType) }}</template>
</el-table-column>
<el-table-column
prop="licensePlate"
label="车牌号"
min-width="160"
show-overflow-tooltip
align="center"
>
<template #default="{ row }">
{{ formatPlVehiclePlateDisplay(row.plan.licensePlate, row.plan.trailerNo) }}
</template>
</el-table-column>
<el-table-column
prop="personnelSummary"
label="人员"
min-width="180"
show-overflow-tooltip
align="center"
>
<template #default="{ row }">
{{ normalizePlPersonnelSummaryDisplay(row.plan.personnelSummary) }}
</template>
</el-table-column>
<el-table-column
prop="stopKind"
label="段次"
min-width="88"
align="center"
class-name="pl-plan-stop-col"
>
<template #default="{ row }">
<span class="pl-plan-stop-kind-text">{{ getStopKindLabel(row) }}</span>
</template>
</el-table-column>
<el-table-column
prop="stopCustomer"
label="客户"
min-width="200"
align="left"
header-align="left"
class-name="col-name-wrap pl-plan-stop-col"
label-class-name="col-name-wrap"
show-overflow-tooltip
>
<template #default="{ row }">
<span>{{ getStopCustomerName(row) }}</span>
<el-tag
v-if="isPlInternalCustomerName(getStopCustomerName(row))"
type="success"
size="small"
class="pl-plan-internal-customer-tag"
>
内部
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="stopAddressTime"
label="地址 / 时间"
min-width="220"
align="left"
header-align="left"
class-name="col-name-wrap pl-plan-stop-col"
label-class-name="col-name-wrap"
>
<template #default="{ row }">
<div class="pl-plan-stop-address-time">
<div class="pl-plan-stop-address-time__addr">{{ getStopAddress(row) }}</div>
<div class="pl-plan-stop-address-time__time">{{ getStopTime(row) }}</div>
</div>
</template>
</el-table-column>
<el-table-column
label="产品名称"
min-width="160"
align="left"
header-align="left"
class-name="col-name-wrap pl-plan-stop-col"
label-class-name="col-name-wrap"
>
<template #default="{ row }">
<PlPlanListProductCell
:stop-products="getStopProducts(row)"
:plan-products="row.plan.products"
field="name"
/>
</template>
</el-table-column>
<el-table-column
label="型号/规格"
min-width="96"
align="center"
header-align="center"
class-name="pl-plan-stop-col"
show-overflow-tooltip
>
<template #default="{ row }">
<PlPlanListProductCell
:stop-products="getStopProducts(row)"
:plan-products="row.plan.products"
field="spec"
/>
</template>
</el-table-column>
<el-table-column
label="包装规格"
min-width="120"
align="center"
header-align="center"
class-name="pl-plan-stop-col"
show-overflow-tooltip
>
<template #default="{ row }">
<PlPlanListProductCell
:stop-products="getStopProducts(row)"
:plan-products="row.plan.products"
field="packageSpec"
/>
</template>
</el-table-column>
<el-table-column
label="单位"
min-width="64"
align="center"
header-align="center"
class-name="pl-plan-stop-col"
>
<template #default="{ row }">
<PlPlanListProductCell
:stop-products="getStopProducts(row)"
:plan-products="row.plan.products"
field="unit"
/>
</template>
</el-table-column>
<el-table-column
label="计划数量"
min-width="100"
align="right"
header-align="right"
class-name="pl-plan-stop-col"
>
<template #default="{ row }">
<PlPlanListProductCell
:stop-products="getStopProducts(row)"
:plan-products="row.plan.products"
field="planQty"
/>
</template>
</el-table-column>
<el-table-column
label="实际数量"
min-width="100"
align="right"
header-align="right"
class-name="pl-plan-stop-col"
>
<template #default="{ row }">
<PlPlanListProductCell
:stop-kind="row.kind"
:stop-products="getStopProducts(row)"
:plan-products="row.plan.products"
field="actualQty"
/>
</template>
</el-table-column>
<el-table-column
label="计划附件"
min-width="200"
align="left"
header-align="left"
class-name="pl-plan-stop-col"
>
<template #default="{ row }">
<PlPlanListCertCell
:products="resolveStopCertProducts(row.plan, getStopProducts(row))"
compact
hide-product
/>
</template>
</el-table-column>
<el-table-column
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)" />
</template>
</el-table-column>
<el-table-column
prop="stopDibang"
label="地磅"
min-width="72"
align="center"
class-name="pl-plan-stop-col"
>
<template #default="{ row }">
<el-tag
v-if="formatStopDibangLabel(row.stop)"
:type="getStopDibangTagType(row.stop)"
size="small"
>
{{ formatStopDibangLabel(row.stop) }}
</el-tag>
<span v-else>—</span>
</template>
</el-table-column>
</ListTable>
<p v-else class="arrange-qty-empty">暂无已安排计划</p>
</div>
</div>
</div>
</div>
<template #footer>
<el-button type="primary" @click="emit('update:modelValue', false)">
@@ -52,12 +302,53 @@
</el-button>
</template>
</el-dialog>
<PlPlanViewDialog
:visible="viewVisible"
:plan-id="viewPlanId"
show-inbound-outbound-qty
@update:visible="viewVisible = $event"
/>
</div>
</template>
<script setup lang="ts">
import { Check } from '@element-plus/icons-vue'
import { planApi, type ContractProductArrangeQtyDetail } from '@/api/pl'
import {
planApi,
type ContractProductArrangeQtyDetail,
type Plan
} from '@/api/pl'
import ListTable from '@/components/business/list-table/index.vue'
import PlPlanViewDialog from '@/components/business/pl-plan-view-dialog/index.vue'
import PlPlanListProductCell from '@/components/business/pl-plan-list-product-cell/index.vue'
import PlPlanListCertCell from '@/components/business/pl-plan-list-cert-cell/index.vue'
import PlPlanContractCell from '@/components/business/pl-plan-contract-cell/index.vue'
import { getPlanTypeLabel } from '@/composables/usePlContractToPlan'
import { pickBxData } from '@/utils/http/bx'
import { isPlInternalCustomerName } from '@/utils/pl-internal-customer'
import {
formatPlVehiclePlateDisplay,
getPlPlanFollowStatusLabel,
getPlPlanFollowStatusTagType,
normalizePlPersonnelSummaryDisplay
} from '@/utils/pl-display'
import {
buildPlPlanListSpanMethod,
flattenPlanListRowsForProduct,
formatStopDibangLabel,
getPlanListDisplayIndex,
getPlPlanListRowClassName,
getStopAddress,
getStopContractLines,
getStopCustomerName,
getStopDibangTagType,
getStopKindLabel,
getStopProducts,
getStopTime,
resolveStopCertProducts,
type PlPlanListDisplayRow
} from '@/utils/pl-plan-list-display-rows'
const props = defineProps<{
modelValue: boolean
@@ -71,6 +362,30 @@
const loading = ref(false)
const detail = ref<ContractProductArrangeQtyDetail | null>(null)
const planList = ref<Plan[]>([])
const viewVisible = ref(false)
const viewPlanId = ref('')
/** 与运输计划列表计划区合并列一致(本弹窗无勾选/链接复制) */
const PLAN_MERGE_COLUMN_PROPS = [
'followStatus',
'planDate',
'planType',
'licensePlate',
'personnelSummary'
] as const
const productIdRef = computed(() => String(props.productId || detail.value?.productId || '').trim())
const displayRows = computed(() =>
flattenPlanListRowsForProduct(planList.value, productIdRef.value)
)
const planListSpanMethod = computed(() =>
buildPlPlanListSpanMethod([...PLAN_MERGE_COLUMN_PROPS], 2)
)
const arrangedPlanCount = computed(() => (detail.value?.plans || []).length)
const dialogTitle = computed(
() => `合同用量 · ${props.productName || detail.value?.productName || '产品'}`
@@ -97,13 +412,18 @@
return Number(n).toFixed(3)
}
function statusLabel(row: { archiveStatus?: string; followStatus?: string; transportStatus?: string }) {
const archive = (row.archiveStatus || '').trim()
if (archive === 'archived') return '已归档'
const follow = (row.followStatus || '').trim()
if (follow === 'completed') return '已完成'
if (follow === 'arranged' || row.transportStatus === 'arranged') return '已安排'
return follow || row.transportStatus || '—'
function getDisplayRowIndex(index: number) {
return getPlanListDisplayIndex(displayRows.value, index, 1, displayRows.value.length || 1)
}
function handleView(plan: Plan) {
if (!plan?.id) return
viewPlanId.value = plan.id
viewVisible.value = true
}
function handleDisplayRowDblClick(row: PlPlanListDisplayRow) {
if (row?.plan) handleView(row.plan)
}
async function loadDetail() {
@@ -111,6 +431,7 @@
const productId = String(props.productId || '').trim()
if (!contractId || !productId) {
detail.value = null
planList.value = []
return
}
loading.value = true
@@ -120,9 +441,29 @@
productId,
excludePlanId: props.excludePlanId || undefined
})
detail.value = pickBxData<ContractProductArrangeQtyDetail>(res) || null
const data = pickBxData<ContractProductArrangeQtyDetail>(res) || null
detail.value = data
const orderedIds = (data?.plans || [])
.map((p) => String(p.planId || '').trim())
.filter(Boolean)
if (!orderedIds.length) {
planList.value = []
return
}
const idSet = new Set(orderedIds)
const listRes = await planApi.list({
contractId,
page: 1,
pageSize: 500
})
const listData = (listRes as any)?.data ?? listRes
const all: Plan[] = listData?.list || []
const byId = new Map(all.filter((p) => p?.id && idSet.has(p.id)).map((p) => [p.id, p]))
planList.value = orderedIds.map((id) => byId.get(id)).filter(Boolean) as Plan[]
} catch {
detail.value = null
planList.value = []
} finally {
loading.value = false
}
@@ -137,8 +478,10 @@
</script>
<style scoped lang="scss">
@use '@styles/business/pl-plan-merge-list-table.scss';
.arrange-qty-body {
min-height: 120px;
min-height: 420px;
}
.arrange-qty-sub {
margin: 0 0 12px;
@@ -148,21 +491,21 @@
.arrange-qty-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-bottom: 14px;
gap: 10px;
margin-bottom: 16px;
}
.arrange-qty-stat {
padding: 10px;
padding: 12px 14px;
border-radius: 8px;
border: 1px solid var(--el-border-color-lighter, #e2e8f0);
background: #f8fafc;
.k {
font-size: 11px;
font-size: 12px;
color: var(--mint-text-muted, #64748b);
}
.v {
margin-top: 2px;
font-size: 15px;
margin-top: 4px;
font-size: 18px;
font-weight: 700;
font-variant-numeric: tabular-nums;
&.is-avail {
@@ -170,14 +513,27 @@
}
}
}
.arrange-qty-list-title {
margin: 0 0 8px;
font-size: 13px;
font-weight: 600;
.arrange-qty-plan-panel {
margin-top: 0;
}
.arrange-qty-empty {
margin: 0;
font-size: 13px;
margin: 16px 0;
font-size: 14px;
color: var(--mint-text-muted, #64748b);
}
.bx-list-table-wrap {
min-width: 0;
}
</style>
<style lang="scss">
.pl-contract-product-arrange-qty-dialog.dialog-form-layout {
width: min(2000px, calc(100vw - 32px)) !important;
max-width: min(2000px, calc(100vw - 32px));
.el-dialog__body {
max-height: calc(100vh - 140px);
min-height: 480px;
}
}
</style>
@@ -33,6 +33,7 @@
:customer-name="meta.customerName"
:generate-dibang="generateDibang"
:dibang-rows="dibangRows"
:plan-product-rows="products"
:driver-feedback="driverFeedback"
:site-feedback="siteFeedback"
:fallback-remark="fallbackRemark"
@@ -159,7 +159,7 @@
class="pl-stop-feedback-dibang-item"
:class="{ 'pl-stop-feedback-dibang-item--split': dibangRows.length > 1 }"
>
<p v-if="dibangRows.length > 1" class="pl-stop-feedback-ticket__product">
<p class="pl-stop-feedback-ticket__product">
{{ dibangProductLabel(row, idx) }}
</p>
<div class="pl-stop-feedback-ticket__qty pl-stop-feedback-ticket__qty--compact">
@@ -219,6 +219,7 @@
import type { TransportStopFeedback } from '@/api/pl'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import { formatKgToTon } from '@/components/business/db/db-related-view'
import type { PlanFormProductRow } from '@/composables/usePlContractToPlan'
import type { PlPlanTransportDibangRow } from '@/composables/usePlPlanViewDetail'
import { formatDateTime } from '@/utils/format'
import { isPlInternalCustomerName } from '@/utils/pl-internal-customer'
@@ -229,6 +230,8 @@
customerName?: string
generateDibang?: number
dibangRows?: PlPlanTransportDibangRow[]
/** 用于解析 CRM 标准产品名(库内 link.productName 可能是别名) */
planProductRows?: PlanFormProductRow[]
driverFeedback?: TransportStopFeedback | null
siteFeedback?: TransportStopFeedback | null
/** 段级无备注时回退展示(如运输单级司机回传备注) */
@@ -245,6 +248,7 @@
customerName: '',
generateDibang: 0,
dibangRows: () => [],
planProductRows: () => [],
driverFeedback: null,
siteFeedback: null,
fallbackRemark: '',
@@ -388,8 +392,37 @@
return { qty: String(qty), unit: ` ${unit}` }
}
function resolvePlanProductRow(row: PlPlanTransportDibangRow) {
const id = String(row.link.planProductId || '').trim()
if (!id) return undefined
return (props.planProductRows || []).find((p) => String(p.id || '').trim() === id)
}
function dibangProductLabel(row: PlPlanTransportDibangRow, idx: number): string {
return row.link.productName || row.inventory?.product || `地磅 ${idx + 1}`
const planRow = resolvePlanProductRow(row)
// 优先 CRM 标准名;link.productName / planRow.productName 可能是别名
const product = (
String(planRow?.standardProductName || '').trim() ||
row.inventory?.product ||
row.weighing?.cargoName ||
String(planRow?.productName || '').trim() ||
row.link.productName ||
''
).trim()
const specification = (
String(planRow?.model || '').trim() ||
row.inventory?.specification ||
row.weighing?.specification ||
''
).trim()
if (!product && !specification) return `地磅 ${idx + 1}`
if (!specification) return product
if (!product) return specification
if (product.endsWith(specification)) {
const base = product.slice(0, -specification.length).trimEnd()
return base ? `${base} ${specification}` : specification
}
return `${product} ${specification}`
}
function dibangBatchNo(row: PlPlanTransportDibangRow): string {
@@ -492,6 +525,11 @@
&--dibang {
background: linear-gradient(90deg, #fff7ed 0%, #f8fafc 100%);
}
// 标题栏与正文第一行间距
& + * {
margin-top: 5px;
}
}
.pl-stop-feedback-ticket__who {
@@ -513,7 +551,7 @@
.pl-stop-feedback-ticket__product {
margin: 0;
padding: 8px 10px 0;
padding: 0 10px;
font-size: 12px;
font-weight: 500;
color: #334155;
@@ -600,7 +638,7 @@
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 10px 0;
padding: 0 10px;
}
.pl-stop-feedback-ticket__product-row {
@@ -644,7 +682,7 @@
}
.pl-stop-feedback-ticket__empty {
padding: 12px 10px;
padding: 7px 10px 12px;
flex: 1;
}
@@ -113,7 +113,8 @@
</div>
</div>
<div class="pl-stop-product-dual-item__row2">
<div class="pl-stop-product-dual-item__field">
<!-- 可安排数量仅运输计划新增/编辑提醒用,详情弹窗不展示 -->
<div v-if="showAvailableQty" class="pl-stop-product-dual-item__field">
<span class="pl-stop-product-dual-item__label">可安排数量</span>
<div class="pl-stop-avail-wrap">
<span class="pl-stop-avail-num">{{ formatAvailableDisplay(row) }}</span>
@@ -276,7 +277,12 @@
</template>
</template>
</el-table-column>
<el-table-column label="可安排数量" min-width="120" align="right">
<el-table-column
v-if="showAvailableQty"
label="可安排数量"
min-width="120"
align="right"
>
<template #default="{ row }">
<div class="pl-stop-avail-wrap pl-stop-avail-wrap--table">
<span class="pl-stop-avail-num">{{ formatAvailableDisplay(row) }}</span>
@@ -419,6 +425,7 @@
/>
<PlContractProductArrangeQtyDialog
v-if="showAvailableQty"
v-model="arrangeQtyDialogVisible"
:contract-id="arrangeQtyContractId"
:product-id="arrangeQtyProductId"
@@ -493,6 +500,8 @@
const canRemove = computed(
() => !!props.editable && (props.allowRemove === undefined ? true : props.allowRemove)
)
/** 仅新增/编辑表单展示可安排数量与问号;详情只读不展示 */
const showAvailableQty = computed(() => !!props.editable)
const dictStore = useDictStore()
@@ -574,11 +583,13 @@
watch(
() =>
[
props.editable,
props.contractId,
props.excludePlanId,
(props.products || []).map((p) => p.productId).join(',')
] as const,
() => {
if (!props.editable) return
const cid = String(props.contractId || '').trim()
if (!cid) return
for (const row of props.products || []) {
@@ -324,14 +324,8 @@
:key="row.link.dbInventoryId || idx"
class="pl-feedback-ticket-qty-block"
>
<p
v-if="dibangPlanRows.length > 1"
class="pl-feedback-ticket-product"
>
<p class="pl-feedback-ticket-product">
{{ dibangProductLabel(row, idx) }}
<template v-if="dibangBatchNo(row) !== '—'">
· {{ dibangBatchNo(row) }}
</template>
</p>
<div class="pl-feedback-ticket-qty">
<div>
@@ -716,8 +710,37 @@
]
}
function resolvePlanProductRow(row: PlPlanTransportDibangRow) {
const id = String(row.link.planProductId || '').trim()
if (!id) return undefined
return (props.planProductRows || []).find((p) => String(p.id || '').trim() === id)
}
function dibangProductLabel(row: PlPlanTransportDibangRow, idx: number): string {
return row.link.productName || row.inventory?.product || `地磅 ${idx + 1}`
const planRow = resolvePlanProductRow(row)
// 优先 CRM 标准名;link.productName / planRow.productName 可能是别名
const product = (
String(planRow?.standardProductName || '').trim() ||
row.inventory?.product ||
row.weighing?.cargoName ||
String(planRow?.productName || '').trim() ||
row.link.productName ||
''
).trim()
const specification = (
String(planRow?.model || '').trim() ||
row.inventory?.specification ||
row.weighing?.specification ||
''
).trim()
if (!product && !specification) return `地磅 ${idx + 1}`
if (!specification) return product
if (!product) return specification
if (product.endsWith(specification)) {
const base = product.slice(0, -specification.length).trimEnd()
return base ? `${base} ${specification}` : specification
}
return `${product} ${specification}`
}
function dibangBatchNo(row: PlPlanTransportDibangRow): string {
@@ -130,6 +130,7 @@
:customer-name="loadView.customerName"
:generate-dibang="loadView.generateDibang"
:dibang-rows="loadDibangRows"
:plan-product-rows="productRows"
:driver-feedback="resolveDriverStopFeedback('load', loadStopId)"
:site-feedback="resolveSiteStopFeedback('load', loadStopId)"
:fallback-remark="transport?.feedbackRemark"
@@ -215,6 +216,7 @@
:customer-name="delivery.customerName"
:generate-dibang="delivery.generateDibang"
:dibang-rows="resolveDeliveryDibangRows(delivery)"
:plan-product-rows="productRows"
:driver-feedback="resolveDriverStopFeedback('delivery', delivery.id || '')"
:site-feedback="resolveSiteStopFeedback('delivery', delivery.id || '')"
:fallback-remark="transport?.feedbackRemark"
@@ -13,11 +13,16 @@ export function usePlPlanMergeListTable(options: {
selectedRows: Ref<Plan[]>
pagination: { page: number; pageSize: number }
onView?: (plan: Plan) => void
/** 计划区固定合并列数:默认 3(勾选+序号+计划编号);无勾选列时传 2 */
fixedMergeColCount?: number
}) {
const displayRows = computed(() => flattenPlanListRows(unref(options.planList)))
const planListSpanMethod = computed(() =>
buildPlPlanListSpanMethod([...options.visiblePlanMergeProps.value])
buildPlPlanListSpanMethod(
[...options.visiblePlanMergeProps.value],
options.fixedMergeColCount
)
)
function getDisplayRowIndex(index: number) {
+4 -1
View File
@@ -18,7 +18,8 @@ import { LanguageEnum } from '@/enums/appEnum'
import { router } from '@/router'
import { useWorktabStore } from './worktab'
import { useMenuFavoriteStore } from './menuFavorite'
import { clearFavoritePreopenFlag } from '@/utils/navigation/preopen-favorites'
import { clearFavoritePreopenFlag } from '@/utils/navigation/preopen-favorites'
import { clearPortalHomeActiveModule } from '@/utils/navigation/portal-home-active-module'
import { AppRouteRecord } from '@/types/router'
import { resetRouterState } from '@/router/guards/beforeEach'
import { useMenuStore } from './menu'
@@ -156,6 +157,7 @@ export const useUserStore = defineStore(
useMenuBadgeStore().clearCounts()
useMenuFavoriteStore().clear()
clearFavoritePreopenFlag()
clearPortalHomeActiveModule()
// 重置路由状态
resetRouterState(500)
// 跳转到登录页,携带当前路由作为 redirect 参数
@@ -194,6 +196,7 @@ export const useUserStore = defineStore(
worktabStore.keepAliveExclude = []
useMenuFavoriteStore().clear()
clearFavoritePreopenFlag()
clearPortalHomeActiveModule()
}
// 清除临时存储
@@ -0,0 +1,28 @@
/** 门户首页侧栏选中模块(会话级,关标签页清空) */
const STORAGE_KEY = 'portal-home-active-module'
export function getPortalHomeActiveModule(): string {
try {
return String(sessionStorage.getItem(STORAGE_KEY) || '').trim()
} catch {
return ''
}
}
export function setPortalHomeActiveModule(key: string): void {
const v = String(key || '').trim()
if (!v) return
try {
sessionStorage.setItem(STORAGE_KEY, v)
} catch {
/* ignore quota / private mode */
}
}
export function clearPortalHomeActiveModule(): void {
try {
sessionStorage.removeItem(STORAGE_KEY)
} catch {
/* ignore */
}
}
+169 -52
View File
@@ -3,11 +3,37 @@ import type { ContractQtyReminder, ContractQtyReminderItem } from '@/api/pl'
export const CONTRACT_QTY_VIOLATION_CODE = 'CONTRACT_QTY_OVERFLOW'
/** 弹窗明细行(超量 / 提醒共用) */
export type ContractQtyDialogRow = {
contractId?: string
contractNo?: string
customerName?: string
stopKind?: string
productId?: string
productName?: string
model?: string
unit?: string
contractQty?: number | null
arrangedTotalQty?: number | null
remainingQty?: number | null
/** framework 无固定量 */
framework?: boolean
overflow?: boolean
}
function formatQty(n: number | undefined | null): string {
if (n == null || Number.isNaN(Number(n))) return '—'
return Number(n).toFixed(3)
}
function escapeHtml(raw: string): string {
return String(raw)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function stopKindLabel(kind?: string): string {
if (kind === 'delivery') return '送货'
if (kind === 'load') return '装货'
@@ -32,60 +58,142 @@ export function extractContractQtyViolationDetail(error: unknown): {
}
}
export function formatContractQtyViolationMessage(
items: Array<Record<string, unknown>>
): string {
if (!items.length) return '合同产品安排数量超过合同数量,请调整后再试。'
const lines = items.map((it) => {
const name = String(it.productName || it.productId || '产品')
const contractNo = String(it.contractNo || it.contractId || '')
const side = stopKindLabel(String(it.stopKind || ''))
const contractQty = formatQty(Number(it.contractQty))
const effective = formatQty(Number(it.effectiveTotal ?? it.arrangedTotalQty))
const overflow = formatQty(Number(it.overflowQty))
const meta = [contractNo, side].filter(Boolean).join(' · ')
return `「${name}」安排后合计 ${effective},超过合同数量 ${contractQty}(超 ${overflow})${meta ? `;${meta}` : ''}`
function normalizeDialogRows(
items: Array<Record<string, unknown> | ContractQtyReminderItem>
): ContractQtyDialogRow[] {
return (items || []).map((raw) => {
const it = raw as Record<string, unknown>
const mode = String(it.mode || '')
const overflowQty = it.overflowQty != null ? Number(it.overflowQty) : null
const remaining =
it.remainingQty != null
? Number(it.remainingQty)
: it.effectiveTotal != null && it.contractQty != null
? Number(it.contractQty) - Number(it.effectiveTotal)
: null
return {
contractId: String(it.contractId || ''),
contractNo: it.contractNo != null ? String(it.contractNo) : '',
customerName: it.customerName != null ? String(it.customerName) : '',
stopKind: it.stopKind != null ? String(it.stopKind) : '',
productId: it.productId != null ? String(it.productId) : '',
productName: it.productName != null ? String(it.productName) : '',
model: it.model != null ? String(it.model) : '',
unit: it.unit != null ? String(it.unit) : '',
contractQty: it.contractQty != null ? Number(it.contractQty) : null,
arrangedTotalQty:
it.arrangedTotalQty != null
? Number(it.arrangedTotalQty)
: it.effectiveTotal != null
? Number(it.effectiveTotal)
: null,
remainingQty: remaining,
framework: mode === 'framework_cumulative',
overflow: overflowQty != null ? overflowQty > 0 : remaining != null && remaining < 0
}
})
return `以下产品「已安排合计 + 本次安排」超过合同数量,请调整后再试:\n\n${lines.join('\n')}`
}
export function formatContractQtyReminderMessage(reminder: ContractQtyReminder): string {
const items = reminder?.items || []
if (!items.length) return ''
const remaining = items.filter((i) => i.mode === 'remaining')
const framework = items.filter((i) => i.mode === 'framework_cumulative')
const blocks: string[] = []
if (remaining.length) {
const lines = remaining.map((it: ContractQtyReminderItem) => {
const name = it.productName || it.productId
const meta = [it.contractNo, stopKindLabel(it.stopKind)].filter(Boolean).join(' · ')
return `「${name}」已安排合计(含本次)${formatQty(it.arrangedTotalQty)},剩余可安排 ${formatQty(it.remainingQty)}${meta ? `;${meta}` : ''}`
})
blocks.push(`合同数量校验通过。各产品剩余可安排量:\n${lines.join('\n')}`)
}
if (framework.length) {
const lines = framework.map((it: ContractQtyReminderItem) => {
const name = it.productName || it.productId
const meta = [it.contractNo, stopKindLabel(it.stopKind)].filter(Boolean).join(' · ')
return `「${name}」累计已安排 ${formatQty(it.arrangedTotalQty)}(本次 ${formatQty(it.thisPlanQty)})${meta ? `;${meta}` : ''}`
})
blocks.push(
`关联框架合同无固定数量上限。本次安排后累计已安排:\n${lines.join('\n')}`
)
}
return blocks.join('\n\n')
function groupKey(row: ContractQtyDialogRow): string {
return `${row.contractId || row.contractNo || ''}::${row.stopKind || ''}`
}
/** @returns true 表示已按合同超量处理(调用方勿再抛出/重复提示) */
function groupTitle(row: ContractQtyDialogRow): string {
const customer = (row.customerName || '').trim() || '客户'
const contractNo = (row.contractNo || '').trim() || '—'
const side = stopKindLabel(row.stopKind)
const base = `${customer} - ${contractNo}`
return side ? `${base}(${side})` : base
}
/** 按合同分组的 HTML 表格(超量 / 剩余 / 框架共用) */
export function buildContractQtyDialogHtml(
items: Array<Record<string, unknown> | ContractQtyReminderItem>,
options?: { intro?: string }
): string {
const rows = normalizeDialogRows(items)
if (!rows.length) {
return `<p style="margin:0;color:#64748b;font-size:13px;">暂无明细</p>`
}
const order: string[] = []
const groups = new Map<string, ContractQtyDialogRow[]>()
for (const row of rows) {
const key = groupKey(row)
if (!groups.has(key)) {
groups.set(key, [])
order.push(key)
}
groups.get(key)!.push(row)
}
const blocks = order.map((key) => {
const list = groups.get(key) || []
const title = groupTitle(list[0])
const body = list
.map((row) => {
const name = escapeHtml(row.productName || row.productId || '—')
const model = escapeHtml(row.model || '—')
const unit = escapeHtml(row.unit || '—')
const contractQty = row.framework
? '按客户需求'
: escapeHtml(formatQty(row.contractQty))
const arranged = escapeHtml(formatQty(row.arrangedTotalQty))
const remainingRaw = row.framework ? '—' : formatQty(row.remainingQty)
const remainingStyle =
row.overflow || (row.remainingQty != null && row.remainingQty < 0)
? 'color:#dc2626;font-weight:600;'
: 'color:#059669;font-weight:600;'
return `<tr>
<td style="padding:8px 10px;border:1px solid #d9e8e2;text-align:left;">${name}</td>
<td style="padding:8px 10px;border:1px solid #d9e8e2;text-align:left;">${model}</td>
<td style="padding:8px 10px;border:1px solid #d9e8e2;text-align:right;font-variant-numeric:tabular-nums;">${contractQty}</td>
<td style="padding:8px 10px;border:1px solid #d9e8e2;text-align:center;">${unit}</td>
<td style="padding:8px 10px;border:1px solid #d9e8e2;text-align:right;font-variant-numeric:tabular-nums;">${arranged}</td>
<td style="padding:8px 10px;border:1px solid #d9e8e2;text-align:right;font-variant-numeric:tabular-nums;${remainingStyle}">${escapeHtml(remainingRaw)}</td>
</tr>`
})
.join('')
return `<div style="margin:0 0 14px;">
<div style="font-size:14px;font-weight:600;color:#1e293b;margin:0 0 8px;padding-left:8px;border-left:3px solid #10b981;">
${escapeHtml(title)}
</div>
<table style="width:100%;border-collapse:collapse;font-size:13px;color:#1e293b;">
<thead>
<tr style="background:#f8fafc;">
<th style="padding:8px 10px;border:1px solid #d9e8e2;text-align:left;font-weight:600;">产品名称</th>
<th style="padding:8px 10px;border:1px solid #d9e8e2;text-align:left;font-weight:600;">规格型号</th>
<th style="padding:8px 10px;border:1px solid #d9e8e2;text-align:right;font-weight:600;">合同数量</th>
<th style="padding:8px 10px;border:1px solid #d9e8e2;text-align:center;font-weight:600;">单位</th>
<th style="padding:8px 10px;border:1px solid #d9e8e2;text-align:right;font-weight:600;">已安排</th>
<th style="padding:8px 10px;border:1px solid #d9e8e2;text-align:right;font-weight:600;">剩余</th>
</tr>
</thead>
<tbody>${body}</tbody>
</table>
</div>`
})
const intro = options?.intro
? `<p style="margin:0 0 12px;font-size:13px;color:#64748b;">${escapeHtml(options.intro)}</p>`
: ''
return `<div style="max-width:720px;text-align:left;">${intro}${blocks.join('')}</div>`
}
/** @returns true 表示已按合同超量处理 */
export async function showContractQtyViolationIfMatch(error: unknown): Promise<boolean> {
const parsed = extractContractQtyViolationDetail(error)
if (!parsed) return false
await ElMessageBox.alert(formatContractQtyViolationMessage(parsed.items), '无法安排运输', {
const html = buildContractQtyDialogHtml(parsed.items, {
intro: '以下产品「已安排 + 本次」超过合同数量,请调整后再安排:'
})
await ElMessageBox.alert(html, '无法安排运输', {
type: 'error',
confirmButtonText: '知道了'
confirmButtonText: '知道了',
dangerouslyUseHTMLString: true,
customClass: 'pl-contract-qty-msgbox',
customStyle: { width: '780px', maxWidth: '92vw' }
})
return true
}
@@ -95,14 +203,23 @@ export async function showContractQtyReminderIfAny(
): Promise<void> {
const items = reminder?.items || []
if (!items.length) return
const message = formatContractQtyReminderMessage(reminder!)
if (!message) return
const hasFramework = items.some((i) => i.mode === 'framework_cumulative')
const title = hasFramework && !items.some((i) => i.mode === 'remaining')
? '安排成功 · 框架合同累计'
: '安排成功 · 合同剩余数量'
await ElMessageBox.alert(message, title, {
const hasRemaining = items.some((i) => i.mode === 'remaining')
let title = '安排成功 · 合同数量提醒'
let intro = '运输安排已成功。各关联合同产品数量如下:'
if (hasFramework && !hasRemaining) {
title = '安排成功 · 框架合同累计'
intro = '运输安排已成功。框架合同无固定数量上限,累计已安排如下:'
} else if (hasRemaining && !hasFramework) {
title = '安排成功 · 合同剩余数量'
intro = '运输安排已成功。各产品剩余可安排量如下:'
}
const html = buildContractQtyDialogHtml(items, { intro })
await ElMessageBox.alert(html, title, {
type: 'success',
confirmButtonText: '知道了'
confirmButtonText: '知道了',
dangerouslyUseHTMLString: true,
customClass: 'pl-contract-qty-msgbox',
customStyle: { width: '780px', maxWidth: '92vw' }
})
}
@@ -77,6 +77,87 @@ export function flattenPlanListRows(plans: Plan[]): PlPlanListDisplayRow[] {
return rows
}
function stopHasProductId(
stop: PlanLoad | PlanDelivery | null | undefined,
productId: string
): boolean {
const pid = String(productId || '').trim()
if (!pid || !stop) return false
return (stop.products || []).some((p) => String(p.productId || '') === pid)
}
/** 仅保留指定产品(计划产品行 + 各段产品行);不含该产品的送货段丢弃。 */
export function filterPlanToProductId(plan: Plan, productId: string): Plan {
const pid = String(productId || '').trim()
if (!pid) return plan
const match = (p: { productId?: string }) => String(p.productId || '') === pid
const products = (plan.products || []).filter(match)
const load = plan.load
? { ...plan.load, products: (plan.load.products || []).filter(match) }
: plan.load
const deliveries = (plan.deliveries || [])
.map((d) => ({ ...d, products: (d.products || []).filter(match) }))
.filter((d) => (d.products || []).length > 0)
return { ...plan, products, load, deliveries }
}
/**
* 展平运输计划列表行,但只保留含指定产品的段次(不展示同计划其他商品)。
* 用于合同用量等「单产品」弹窗列表。
*/
export function flattenPlanListRowsForProduct(
plans: Plan[],
productId: string
): PlPlanListDisplayRow[] {
const pid = String(productId || '').trim()
if (!pid) return flattenPlanListRows(plans)
const rows: PlPlanListDisplayRow[] = []
let planListIndex = 0
for (const raw of plans || []) {
if (!raw?.id) continue
const plan = filterPlanToProductId(raw, pid)
const { load, deliveries } = resolvePlanStopsForList(plan)
type StopSlot = {
kind: PlPlanListDisplayRowKind
stop: PlanLoad | PlanDelivery
deliverySeq?: number
}
const stops: StopSlot[] = []
if (stopHasProductId(load, pid)) {
stops.push({ kind: 'load', stop: load })
}
deliveries.forEach((delivery, index) => {
if (!stopHasProductId(delivery, pid)) return
stops.push({
kind: 'delivery',
stop: delivery,
deliverySeq: delivery.seq ?? index + 1
})
})
if (!stops.length) continue
const planRowSpan = stops.length
stops.forEach((slot, index) => {
const isLoad = slot.kind === 'load'
rows.push({
rowKey: isLoad
? `${plan.id}-load`
: `${plan.id}-delivery-${slot.deliverySeq ?? index + 1}`,
plan,
kind: slot.kind,
stop: slot.stop,
deliverySeq: slot.deliverySeq,
planListIndex,
isPlanFirstRow: index === 0,
planRowSpan,
isPlanLastRow: index === stops.length - 1
})
})
planListIndex += 1
}
return rows
}
export function getPlanListDisplayIndex(
displayRows: PlPlanListDisplayRow[],
displayRowIndex: number,
@@ -102,14 +183,16 @@ export function isPlPlanListPlanMergeColumn(
}
export function buildPlPlanListSpanMethod(
visiblePlanProps: string[]
visiblePlanProps: string[],
fixedMergeColCount: number = PLAN_FIXED_MERGE_COL_COUNT
): (args: {
row: PlPlanListDisplayRow
column: { type?: string; property?: string }
rowIndex: number
columnIndex: number
}) => { rowspan: number; colspan: number } | undefined {
const planMergeCount = PLAN_FIXED_MERGE_COL_COUNT + visiblePlanProps.length
const fixed = Math.max(0, fixedMergeColCount)
const planMergeCount = fixed + visiblePlanProps.length
return ({ row, columnIndex }) => {
if (columnIndex >= planMergeCount) return undefined
if (row.isPlanFirstRow) {
@@ -120,7 +203,10 @@ export function buildPlPlanListSpanMethod(
}
export function getPlPlanListRowClassName({ row }: { row: PlPlanListDisplayRow }): string {
const classes = [row.kind === 'load' ? 'pl-plan-list-row--load' : 'pl-plan-list-row--delivery']
const classes = [
'pl-plan-list-row',
row.kind === 'load' ? 'pl-plan-list-row--load' : 'pl-plan-list-row--delivery'
]
if (row.planListIndex % 2 === 1) classes.push('pl-plan-list-row--plan-striped')
if (row.isPlanLastRow) classes.push('pl-plan-list-row--plan-end')
return classes.join(' ')
+5 -1
View File
@@ -111,7 +111,7 @@ export interface PlanDeliveryForm {
products: PlanStopProductFormRow[]
}
/** 地磅默认勾选以本段客户是否为内部客户(山峡/云栋)为准。 */
/** 地磅勾选仅对内部客户(山峡/云栋)生效:默认勾选且不可改;其他客户默认不勾且不可勾。 */
export function resolveStopDibangDefaults(
customerName?: string | null,
stopType: 'load' | 'delivery' = 'load'
@@ -132,6 +132,10 @@ function normalizeStopDibangFieldsFromApi(
dibangDirection?: string | null
): { generateDibang: number; dibangDirection: string } {
const defaults = resolveStopDibangDefaults(customerName, stopType)
// 非山峡/云栋:禁止生成地磅单(编辑回填也强制清掉历史勾选)
if (!isPlInternalCustomerName(customerName)) {
return { generateDibang: 0, dibangDirection: 'none' }
}
const gen = Number(generateDibang ?? defaults.generateDibang) === 1 ? 1 : 0
let direction = (dibangDirection || '').trim()
if (gen) {
@@ -262,6 +262,42 @@
<span v-else class="money-display">¥{{ parseFloat(scope.row.taxAmount || 0).toFixed(2) }}</span>
</template>
</el-table-column>
<el-table-column
label="已归档数量"
prop="archivedQty"
min-width="110"
align="right"
class-name="col-plan-qty"
label-class-name="col-plan-qty"
>
<template #default="{ row }">
<span class="plan-qty-num">{{ formatPlanQty(row.archivedQty) }}</span>
</template>
</el-table-column>
<el-table-column
label="未归档已安排"
prop="arrangedUnarchivedQty"
min-width="120"
align="right"
class-name="col-plan-qty"
label-class-name="col-plan-qty"
>
<template #default="{ row }">
<span class="plan-qty-num">{{ formatPlanQty(row.arrangedUnarchivedQty) }}</span>
</template>
</el-table-column>
<el-table-column
label="已安排合计"
prop="arrangedTotalQty"
min-width="110"
align="right"
class-name="col-plan-qty"
label-class-name="col-plan-qty"
>
<template #default="{ row }">
<span class="plan-qty-num">{{ formatPlanQty(row.arrangedTotalQty) }}</span>
</template>
</el-table-column>
<el-table-column label="包装规格" prop="packageSpec" min-width="150" :show-overflow-tooltip="true">
<template #default="scope">
<span>{{ scope.row.packageSpec || '-' }}</span>
@@ -397,6 +433,14 @@
<el-empty v-else description="加载中..." />
</el-tab-pane>
<!-- 运输计划 -->
<el-tab-pane label="运输计划" name="plans">
<PlContractLinkedPlansPanel
:contract-id="detailContract?.id"
:active="detailActiveTab === 'plans'"
/>
</el-tab-pane>
<!-- 回款记录 -->
<el-tab-pane label="回款记录" name="payment">
<div class="panel dialog-list-panel">
@@ -917,6 +961,7 @@ import UploadDialog from './UploadDialog.vue';
import ContractFormDialog from './ContractFormDialog.vue';
import ViewKvTable from '@/components/business/system-view-dialog/ViewKvTable.vue';
import type { ViewField } from '@/components/business/system-view-dialog/types';
import PlContractLinkedPlansPanel from '@/components/business/pl-contract-linked-plans-panel/index.vue';
import {
parseSettlementClauseDrafts,
resolveSettlementClauseContent
@@ -3092,6 +3137,12 @@ const handleGeneratePdfFromDialog = async () => {
onMounted(async () => {
await dictStore.loadAllDict();
});
function formatPlanQty(value: unknown) {
const n = parseFloat(String(value ?? 0))
if (Number.isNaN(n)) return '0.000'
return n.toFixed(3)
}
</script>
<style scoped lang="scss">
.framework-tier-detail-block {
@@ -3102,6 +3153,20 @@ onMounted(async () => {
color: var(--el-text-color-primary);
}
}
:deep(th.col-plan-qty),
:deep(td.col-plan-qty) {
color: #059669 !important;
font-weight: 600;
}
:deep(th.col-plan-qty) {
background: #ecfdf5 !important;
}
.plan-qty-num {
color: #059669;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.tier-letter-thumbs {
display: flex;
flex-wrap: wrap;
@@ -336,6 +336,42 @@
<span v-else>¥{{ parseFloat(scope.row.taxAmount || 0).toFixed(2) }}</span>
</template>
</el-table-column>
<el-table-column
label="已归档数量"
prop="archivedQty"
min-width="110"
align="right"
class-name="col-plan-qty"
label-class-name="col-plan-qty"
>
<template #default="{ row }">
<span class="plan-qty-num">{{ formatPlanQty(row.archivedQty) }}</span>
</template>
</el-table-column>
<el-table-column
label="未归档已安排"
prop="arrangedUnarchivedQty"
min-width="120"
align="right"
class-name="col-plan-qty"
label-class-name="col-plan-qty"
>
<template #default="{ row }">
<span class="plan-qty-num">{{ formatPlanQty(row.arrangedUnarchivedQty) }}</span>
</template>
</el-table-column>
<el-table-column
label="已安排合计"
prop="arrangedTotalQty"
min-width="110"
align="right"
class-name="col-plan-qty"
label-class-name="col-plan-qty"
>
<template #default="{ row }">
<span class="plan-qty-num">{{ formatPlanQty(row.arrangedTotalQty) }}</span>
</template>
</el-table-column>
<el-table-column label="包装规格" prop="packageSpec" min-width="150" :show-overflow-tooltip="true">
<template #default="scope">
<span>{{ scope.row.packageSpec || '-' }}</span>
@@ -462,6 +498,14 @@
<el-empty v-else description="加载中..." />
</el-tab-pane>
<!-- 运输计划 -->
<el-tab-pane label="运输计划" name="plans">
<PlContractLinkedPlansPanel
:contract-id="contract?.id"
:active="activeTab === 'plans'"
/>
</el-tab-pane>
<!-- 回款记录 -->
<el-tab-pane label="回款记录" name="payment">
<el-button type="primary" icon="Plus" @click="handleAddPayment" class="mb-3">新增回款</el-button>
@@ -1092,6 +1136,7 @@ import ExecuteDialog from './components/ExecuteDialog.vue';
import InvalidDialog from './components/InvalidDialog.vue';
import RenewDialog from './components/RenewDialog.vue';
import UploadDialog from './components/UploadDialog.vue';
import PlContractLinkedPlansPanel from '@/components/business/pl-contract-linked-plans-panel/index.vue';
import CustomerDetailDialog from '@/views/crm/customer/CustomerDetailDialog.vue';
import ImagePreview from '@/components/ImagePreview/index.vue';
import TinyMceEditor from '@/components/TinyMceEditor/index.vue';
@@ -2902,10 +2947,30 @@ const handleGeneratePdfFile = async () => {
pdfGenerating.value = false;
}
};
function formatPlanQty(value: unknown) {
const n = parseFloat(String(value ?? 0))
if (Number.isNaN(n)) return '0.000'
return n.toFixed(3)
}
</script>
<style scoped lang="scss">
.contract-detail-page {
:deep(th.col-plan-qty),
:deep(td.col-plan-qty) {
color: #059669 !important;
font-weight: 600;
}
:deep(th.col-plan-qty) {
background: #ecfdf5 !important;
}
.plan-qty-num {
color: #059669;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.detail-subtitle {
margin: 0;
padding: 0 16px 12px;
+9 -59
View File
@@ -298,16 +298,8 @@
header-align="center"
>
<template #default="{ row }">
<div
v-if="
row.plan.canCopyToDriver ||
row.plan.canCopyToSite ||
row.plan.canCopyPoundQrcode
"
class="pl-plan-arrange-copy-cell"
>
<div v-if="row.plan.canCopyPoundQrcode" class="pl-plan-arrange-copy-cell">
<el-button
v-if="row.plan.canCopyPoundQrcode"
class="btn-ghost"
title="复制磅单二维码"
:loading="
@@ -317,28 +309,6 @@
>
磅单码
</el-button>
<el-button
v-if="row.plan.canCopyToDriver"
class="btn-ghost"
title="复制给司机"
:loading="
arrangeCopyLoadingId === row.plan.id && arrangeCopyLoadingAction === 'driver'
"
@click.stop="handleListCopyDriver(row.plan)"
>
司机
</el-button>
<el-button
v-if="row.plan.canCopyToSite"
class="btn-ghost"
title="复制给现场"
:loading="
arrangeCopyLoadingId === row.plan.id && arrangeCopyLoadingAction === 'site'
"
@click.stop="handleListCopySite(row.plan)"
>
现场
</el-button>
</div>
<span v-else>—</span>
</template>
@@ -932,31 +902,21 @@
poundQrcodePickVisible,
poundQrcodePickRecords,
loadArrangeActions,
copySiteSummary,
copyPlanSummary,
copyPoundSlipQrcode,
handlePoundQrcodePickCopy,
handlePoundQrcodePickPreview
} = usePlPlanArrangeActions()
const arrangeCopyLoadingId = ref('')
const arrangeCopyLoadingAction = ref<'driver' | 'site' | 'qrcode' | ''>('')
const arrangeCopyLoadingAction = ref<'qrcode' | ''>('')
type ArrangeCopyAction = 'driver' | 'site' | 'qrcode'
async function runListArrangeCopy(row: Plan, action: ArrangeCopyAction) {
async function handleListCopyPoundQrcode(row: Plan) {
if (!row.id || arrangeCopyLoadingId.value) return
arrangeCopyLoadingId.value = row.id
arrangeCopyLoadingAction.value = action
arrangeCopyLoadingAction.value = 'qrcode'
try {
await loadArrangeActions(row.id)
if (action === 'driver') {
await copyPlanSummary()
} else if (action === 'site') {
await copySiteSummary()
} else {
await copyPoundSlipQrcode()
}
await copyPoundSlipQrcode()
} catch {
ElMessage.error('复制失败,请稍后重试')
} finally {
@@ -965,18 +925,6 @@
}
}
function handleListCopyDriver(row: Plan) {
void runListArrangeCopy(row, 'driver')
}
function handleListCopySite(row: Plan) {
void runListArrangeCopy(row, 'site')
}
function handleListCopyPoundQrcode(row: Plan) {
void runListArrangeCopy(row, 'qrcode')
}
const selectedRows = ref<Plan[]>([])
const planStatsRowColumns = computed((): StatsRowColumn[] => {
@@ -1756,8 +1704,10 @@
background-color: #ecfdf5 !important;
}
tbody tr.pl-plan-list-row:hover > td.el-table__cell {
background-color: var(--el-table-row-hover-bg-color, #f1f5f9) !important;
/* 悬停用浅红;须压过斑马纹/装货段 !important 底色 */
tbody tr.pl-plan-list-row:hover > td.el-table__cell,
tbody tr.pl-plan-list-row.hover-row > td.el-table__cell {
background-color: #fee2e2 !important;
}
.pl-plan-list-row--plan-end > td {
+6 -1
View File
@@ -194,6 +194,10 @@
import { useMenuFavoriteStore } from '@/store/modules/menuFavorite'
import { useUserStore } from '@/store/modules/user'
import { refreshMenuBadges } from '@/utils/menu-badge-registry'
import {
getPortalHomeActiveModule,
setPortalHomeActiveModule
} from '@/utils/navigation/portal-home-active-module'
import {
collectPortalHomeSections,
findHomeCardByPath,
@@ -232,7 +236,7 @@
const { items: favItems } = storeToRefs(favStore)
const { counts: badgeCounts, loaded: badgeLoaded } = storeToRefs(badgeStore)
const activeModule = ref(FAV_KEY)
const activeModule = ref(getPortalHomeActiveModule() || FAV_KEY)
const favoriteList = ref<PortalHomeCardItem[]>([])
const favDragging = ref(false)
const suppressFavClick = ref(false)
@@ -393,6 +397,7 @@
}
watch(modules, ensureActiveModule, { immediate: true, deep: true })
watch(activeModule, (key) => setPortalHomeActiveModule(key))
watch([favItems, modules], syncFavoriteList, { immediate: true, deep: true })
onMounted(async () => {